@tikoci/centrs 0.1.7 → 0.1.8

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 (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. package/src/transfer.ts +234 -32
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { mapRouterOsError } from "../core/routeros-errors.ts";
18
18
  import { CentrsError } from "../errors.ts";
19
+ import { formatEndpoint } from "./endpoint.ts";
19
20
  import type { RouterOsProtocol } from "./index.ts";
20
21
  import {
21
22
  createUdpMacTelnetTransport,
@@ -28,6 +29,7 @@ import {
28
29
  import { MacTelnetConsole } from "./mac-telnet-console.ts";
29
30
  import {
30
31
  type ApiReply,
32
+ byteStringBytes,
31
33
  connectNativeApi,
32
34
  type NativeApiCommand,
33
35
  type NativeApiSession,
@@ -52,6 +54,8 @@ export interface ProtocolAdapterConfig {
52
54
  password: string;
53
55
  /** Per-command timeout in milliseconds. */
54
56
  timeoutMs: number;
57
+ /** Abandon every operation, including validation and native connection/login. */
58
+ signal?: AbortSignal;
55
59
  /** Target device MAC, for L2 transports (mac-telnet). */
56
60
  mac?: string;
57
61
  /** Client (in-packet source) MAC for mac-telnet; a synthetic one is used when omitted. */
@@ -67,9 +71,20 @@ export interface ProtocolAdapterConfig {
67
71
  }
68
72
 
69
73
  /** Projection/detail options for a menu `list`. */
70
- export interface RetrieveListOptions {
74
+ export interface RetrieveListOptions extends RetrieveReadOptions {
71
75
  proplist?: readonly string[];
72
76
  detail?: boolean;
77
+ /** Row filter in REST `.query` form (no leading `?`); native prefixes each with `?`. */
78
+ query?: readonly string[];
79
+ }
80
+
81
+ /** Options every retrieve read takes. */
82
+ export interface RetrieveReadOptions {
83
+ /**
84
+ * Abandon the read: REST aborts the HTTP request at once. Native reads
85
+ * share the session, so they end when the adapter is closed.
86
+ */
87
+ signal?: AbortSignal;
73
88
  }
74
89
 
75
90
  /** What an adapter can do. Lets callers pick a transport per capability. */
@@ -96,6 +111,14 @@ export interface ProtocolExecuteRequest {
96
111
  queries?: readonly string[];
97
112
  /** Raw CLI line for the `/rest/execute` script fallback. */
98
113
  script?: string;
114
+ /**
115
+ * Attribute values and reply strings are byte strings, one code unit per
116
+ * byte (0–255), instead of UTF-8 text. RouterOS carries `/file` contents
117
+ * as raw bytes on both wires, so this is how `transfer` moves arbitrary
118
+ * bytes (#404); a non-ASCII value such as a file name must be passed as its
119
+ * UTF-8 byte string. Honored by REST and native-api; not with `script`.
120
+ */
121
+ binary?: boolean;
99
122
  }
100
123
 
101
124
  /** Result of an `execute` round-trip. */
@@ -134,6 +157,21 @@ export interface ProtocolApiRequest {
134
157
  proplist?: readonly string[];
135
158
  /** Raw console line for the `/execute` script form (`run`); when set, `path`/`attributes` are ignored. */
136
159
  script?: string;
160
+ /** `print` only: send the menu's `listen` command instead (the literal `<menu>/listen` endpoint). */
161
+ listen?: boolean;
162
+ }
163
+
164
+ /**
165
+ * One reply of an incremental native command, as RouterOS sent it: an `!re`
166
+ * row, or an `!empty` (zero rows: an empty finite read, a zero-row
167
+ * `print interval=` tick, or a listen cancelled before it sent anything).
168
+ */
169
+ export interface ProtocolStreamReply {
170
+ type: "re" | "empty";
171
+ /** The row's attributes; always `{}` for `!empty`. */
172
+ attributes: Record<string, string>;
173
+ /** The reply arrived after `options.signal` aborted (see `ApiReply.afterStop`). */
174
+ afterStop?: true;
137
175
  }
138
176
 
139
177
  /** Result of an `apiRequest` round-trip: the response body shaped for the envelope. */
@@ -142,7 +180,7 @@ export interface ProtocolApiResult {
142
180
  data: unknown;
143
181
  }
144
182
 
145
- /** Options for an open-ended `listen` follow. */
183
+ /** Options for incremental native command replies. */
146
184
  export interface ProtocolListenOptions {
147
185
  /** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
148
186
  signal?: AbortSignal;
@@ -160,6 +198,21 @@ export interface ProtocolListenOptions {
160
198
  cancelGraceMs?: number;
161
199
  /** Fired when that wait expires without the router's acknowledgement. */
162
200
  onCancelUnacknowledged?: () => void;
201
+ /** Attributes carried by the command's terminal `!done`, not a row frame. */
202
+ onDone?: (attributes: Record<string, string>) => void;
203
+ /**
204
+ * Every reply (`re`, `empty`, `trap`, `done`), called synchronously in wire
205
+ * order as it arrives, before the generator yields it. Concurrent streams
206
+ * on one connection can be ordered against each other only this way; the
207
+ * generators' own interleaving depends on microtask scheduling.
208
+ */
209
+ onReply?: (reply: ProtocolTappedReply) => void;
210
+ }
211
+
212
+ /** A reply seen by {@link ProtocolListenOptions.onReply}. */
213
+ export interface ProtocolTappedReply {
214
+ type: "re" | "empty" | "trap" | "done";
215
+ attributes: Record<string, string>;
163
216
  }
164
217
 
165
218
  /**
@@ -173,9 +226,14 @@ export interface ProtocolAdapter {
173
226
  /** Capability flags describing what this adapter supports. */
174
227
  readonly capabilities: ProtocolAdapterCapabilities;
175
228
  /** `/console/inspect` probe (`request=child` or `request=completion`). */
176
- inspect(request: "child" | "completion", path: string): Promise<unknown[]>;
229
+ /** `/console/inspect`; `input` is the CLI-text form (`request=completion input=…`). */
230
+ inspect(
231
+ request: "child" | "completion",
232
+ path: string,
233
+ input?: string,
234
+ ): Promise<unknown[]>;
177
235
  /** Read a single record (singleton menu) as an object. */
178
- getSingleton(path: string): Promise<unknown>;
236
+ getSingleton(path: string, options?: RetrieveReadOptions): Promise<unknown>;
179
237
  /** Read a menu as an array of records, optionally projected/detailed. */
180
238
  list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
181
239
  /** Run a CLI-shaped command (WP-1c write surface). */
@@ -183,15 +241,16 @@ export interface ProtocolAdapter {
183
241
  /** Run a normalized structured `api` request (the gh-api passthrough surface). */
184
242
  apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
185
243
  /**
186
- * Open-ended `/listen` follow (native-api only). Yields one rest-style change
187
- * record per `!re` frame (deletions carry `.dead`), until cancelled via
188
- * `options.signal` or the consumer stopping. REST and console transports
189
- * reject with `transport/capability-unsupported`.
244
+ * Incremental native replies to the literal command (the same mapping as
245
+ * `apiRequest`; never rewritten into a `listen`). Yields each `!re` and
246
+ * `!empty` as it arrives, until natural completion, cancellation, or
247
+ * failure. REST and console transports reject with
248
+ * `transport/capability-unsupported`.
190
249
  */
191
- listen(
250
+ stream(
192
251
  request: ProtocolApiRequest,
193
252
  options: ProtocolListenOptions,
194
- ): AsyncIterable<Record<string, unknown>>;
253
+ ): AsyncIterable<ProtocolStreamReply>;
195
254
  /** Release any underlying connection. Safe to call when never connected. */
196
255
  close(): Promise<void>;
197
256
  }
@@ -236,28 +295,48 @@ class RestAdapter implements ProtocolAdapter {
236
295
  async inspect(
237
296
  request: "child" | "completion",
238
297
  path: string,
298
+ input?: string,
239
299
  ): Promise<unknown[]> {
240
- return this.restPost<unknown[]>("/console/inspect", { request, path });
300
+ return this.restPost<unknown[]>(
301
+ "/console/inspect",
302
+ input === undefined ? { request, path } : { request, input },
303
+ );
241
304
  }
242
305
 
243
- async getSingleton(path: string): Promise<unknown> {
244
- return this.restGet(path);
306
+ async getSingleton(
307
+ path: string,
308
+ options: RetrieveReadOptions = {},
309
+ ): Promise<unknown> {
310
+ return this.restGet(path, options.signal);
245
311
  }
246
312
 
247
313
  async list(path: string, options: RetrieveListOptions): Promise<unknown[]> {
248
- const hasProjection =
249
- (options.proplist?.length ?? 0) > 0 || options.detail === true;
250
- if (!hasProjection) {
251
- return (await this.restGet(path)) as unknown[];
314
+ const hasBody =
315
+ (options.proplist?.length ?? 0) > 0 ||
316
+ (options.query?.length ?? 0) > 0 ||
317
+ options.detail === true;
318
+ if (!hasBody) {
319
+ return (await this.restGet(path, options.signal)) as unknown[];
252
320
  }
253
- const body: { ".proplist"?: readonly string[]; detail?: string } = {};
321
+ const body: {
322
+ ".proplist"?: readonly string[];
323
+ ".query"?: readonly string[];
324
+ detail?: string;
325
+ } = {};
254
326
  if (options.proplist && options.proplist.length > 0) {
255
327
  body[".proplist"] = options.proplist;
256
328
  }
329
+ if (options.query && options.query.length > 0) {
330
+ body[".query"] = options.query;
331
+ }
257
332
  if (options.detail) {
258
333
  body.detail = "true";
259
334
  }
260
- return this.restPost<unknown[]>(`${path.replace(/\/$/, "")}/print`, body);
335
+ return this.restPost<unknown[]>(
336
+ `${path.replace(/\/$/, "")}/print`,
337
+ body,
338
+ options.signal,
339
+ );
261
340
  }
262
341
 
263
342
  async execute(
@@ -271,6 +350,21 @@ class RestAdapter implements ProtocolAdapter {
271
350
  return normalizeRestExecute(data);
272
351
  }
273
352
  const target = `${request.path.replace(/\/$/, "")}/${request.command}`;
353
+ if (request.binary) {
354
+ // RouterOS stores the UTF-8 encoding of each JSON code point, so a
355
+ // `\u0080` escape lands as `c2 80`; only raw bytes in the body are
356
+ // exact. Replies carry 0x80–0xff unescaped too (CHR, #404).
357
+ const response = await this.fetchRest(
358
+ target,
359
+ {
360
+ method: "POST",
361
+ body: byteStringBytes(JSON.stringify(request.attributes ?? {})),
362
+ headers: { "Content-Type": "application/json" },
363
+ },
364
+ true,
365
+ );
366
+ return normalizeRestExecute(response.data);
367
+ }
274
368
  const data = await this.restPost<unknown>(target, request.attributes ?? {});
275
369
  return normalizeRestExecute(data);
276
370
  }
@@ -333,7 +427,10 @@ class RestAdapter implements ProtocolAdapter {
333
427
  return { data: result.ret ?? result.records };
334
428
  }
335
429
  return {
336
- data: await this.requestRest("POST", base, request.attributes ?? {}),
430
+ data: await this.requestRest("POST", base, {
431
+ ...request.attributes,
432
+ ...restQueryBody(request),
433
+ }),
337
434
  };
338
435
  }
339
436
  default:
@@ -341,15 +438,15 @@ class RestAdapter implements ProtocolAdapter {
341
438
  }
342
439
  }
343
440
 
344
- // REST cannot hold an open-ended follow (the 60s cap); listen is native-only.
441
+ // REST cannot hold an open-ended follow (the 60s cap); streaming is native-only.
345
442
  // biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
346
- async *listen(): AsyncGenerator<Record<string, unknown>> {
443
+ async *stream(): AsyncGenerator<ProtocolStreamReply> {
347
444
  throw new CentrsError({
348
445
  code: "transport/capability-unsupported",
349
446
  summary: "REST cannot follow an open-ended `--stream` (60s cap).",
350
447
  remediation:
351
448
  "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
352
- context: { protocol: this.protocol, capability: "listen" },
449
+ context: { protocol: this.protocol, capability: "stream" },
353
450
  });
354
451
  }
355
452
 
@@ -374,19 +471,21 @@ class RestAdapter implements ProtocolAdapter {
374
471
  return response.data;
375
472
  }
376
473
 
377
- private async restGet(path: string): Promise<unknown> {
378
- const response = await this.fetchRest(path, { method: "GET" });
474
+ private async restGet(path: string, signal?: AbortSignal): Promise<unknown> {
475
+ const response = await this.fetchRest(path, { method: "GET", signal });
379
476
  return response.data;
380
477
  }
381
478
 
382
479
  private async restPost<T = unknown>(
383
480
  path: string,
384
481
  body: Record<string, unknown>,
482
+ signal?: AbortSignal,
385
483
  ): Promise<T> {
386
484
  const response = await this.fetchRest(path, {
387
485
  method: "POST",
388
486
  body: JSON.stringify(body),
389
487
  headers: { "Content-Type": "application/json" },
488
+ signal,
390
489
  });
391
490
  return response.data as T;
392
491
  }
@@ -394,6 +493,7 @@ class RestAdapter implements ProtocolAdapter {
394
493
  private async fetchRest(
395
494
  path: string,
396
495
  init: RequestInit,
496
+ binary = false,
397
497
  ): Promise<RestResponse> {
398
498
  const url = joinRestUrl(this.config.baseUrl, path);
399
499
  const headers = new Headers(init.headers);
@@ -418,13 +518,25 @@ class RestAdapter implements ProtocolAdapter {
418
518
  } = {
419
519
  ...init,
420
520
  headers,
421
- signal: controller.signal,
521
+ // A caller's signal abandons the request; the timeout still applies.
522
+ signal: AbortSignal.any([
523
+ controller.signal,
524
+ ...[init.signal, this.config.signal].filter(
525
+ (signal): signal is AbortSignal =>
526
+ signal !== undefined && signal !== null,
527
+ ),
528
+ ]),
422
529
  tls: { rejectUnauthorized: this.config.insecure !== true },
423
530
  };
424
531
 
425
532
  try {
426
533
  const response = await fetch(url, fetchInit);
427
- const text = await response.text();
534
+ // A binary reply decodes as a byte string through Buffer's latin1;
535
+ // WHATWG latin1 is windows-1252 and loses 0x80–0x9f. Errors stay UTF-8
536
+ // text for the message.
537
+ const text = Buffer.from(await response.arrayBuffer()).toString(
538
+ binary && response.ok ? "latin1" : "utf8",
539
+ );
428
540
  const data = parseResponseBody(text);
429
541
  if (!response.ok) {
430
542
  throw this.mapHttpFailure(response.status, text, data, path);
@@ -451,7 +563,7 @@ class RestAdapter implements ProtocolAdapter {
451
563
  if (status === 401 || status === 403) {
452
564
  return new CentrsError({
453
565
  code: "transport/auth-failed",
454
- summary: `RouterOS rejected the REST credentials for ${host}:${port}.`,
566
+ summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
455
567
  remediation:
456
568
  "Check `--username` / `--password` or the matching `CENTRS_*` environment variables, and confirm the user has RouterOS REST access.",
457
569
  context: { via: protocol, host, port, path, status },
@@ -497,7 +609,7 @@ class RestAdapter implements ProtocolAdapter {
497
609
  ) {
498
610
  return new CentrsError({
499
611
  code: "transport/timeout",
500
- summary: `Timed out waiting for ${protocol} to respond from ${host}:${port}.`,
612
+ summary: `Timed out waiting for ${protocol} to respond from ${formatEndpoint(host, port)}.`,
501
613
  remediation:
502
614
  "Increase `--timeout` within the REST ceiling, or confirm the host and port are reachable.",
503
615
  context: { via: protocol, host, port, path, timeoutMs },
@@ -518,7 +630,7 @@ class RestAdapter implements ProtocolAdapter {
518
630
  ) {
519
631
  return new CentrsError({
520
632
  code: "transport/connection-refused",
521
- summary: `Connection refused by ${host}:${port} over ${protocol}.`,
633
+ summary: `Connection refused by ${formatEndpoint(host, port)} over ${protocol}.`,
522
634
  remediation:
523
635
  "Check that the RouterOS REST service is enabled on that port and that any local forwarding or firewall rules are correct.",
524
636
  context: { via: protocol, host, port, path },
@@ -550,7 +662,7 @@ class RestAdapter implements ProtocolAdapter {
550
662
  if (messages.some((candidate) => candidate.includes("certificate"))) {
551
663
  return new CentrsError({
552
664
  code: "transport/tls-certificate",
553
- summary: `TLS certificate validation failed for ${host}:${port}.`,
665
+ summary: `TLS certificate validation failed for ${formatEndpoint(host, port)}.`,
554
666
  remediation:
555
667
  "RouterOS ships a self-signed certificate by default. Install a trusted chain, or pass `--insecure` (`CENTRS_INSECURE=1`) to accept it; an HTTP URL also avoids TLS.",
556
668
  context: { via: protocol, host, port, path },
@@ -560,7 +672,7 @@ class RestAdapter implements ProtocolAdapter {
560
672
 
561
673
  return new CentrsError({
562
674
  code: "transport/network",
563
- summary: `Network request to ${host}:${port} failed over ${protocol}.`,
675
+ summary: `Network request to ${formatEndpoint(host, port)} failed over ${protocol}.`,
564
676
  remediation:
565
677
  "Check the host, port, and service availability, then re-run with `--format json` if you need the structured cause data.",
566
678
  context: { via: protocol, host, port, path },
@@ -569,6 +681,34 @@ class RestAdapter implements ProtocolAdapter {
569
681
  }
570
682
  }
571
683
 
684
+ /** One native sentence shape for one-shot and incremental API requests. */
685
+ function nativeCommandFor(request: ProtocolApiRequest): NativeApiCommand {
686
+ const base = request.path.replace(/\/$/, "");
687
+ const command: NativeApiCommand = {
688
+ command:
689
+ request.script !== undefined
690
+ ? "/execute"
691
+ : request.verb === "run"
692
+ ? base
693
+ : request.verb === "print" && request.listen
694
+ ? `${base}/listen`
695
+ : `${base}/${request.verb}`,
696
+ attributes:
697
+ request.script !== undefined
698
+ ? { script: request.script, "as-string": "" }
699
+ : { ...request.attributes },
700
+ };
701
+ if (request.verb === "set" || request.verb === "remove") {
702
+ command.attributes = { ...command.attributes, ".id": request.id ?? "" };
703
+ }
704
+ const queries = (request.query ?? []).map((word) => `?${word}`);
705
+ if (request.id && request.verb === "print")
706
+ queries.push(`?.id=${request.id}`);
707
+ if (queries.length > 0) command.queries = queries;
708
+ if (request.proplist?.length) command.proplist = request.proplist;
709
+ return command;
710
+ }
711
+
572
712
  class NativeApiAdapter implements ProtocolAdapter {
573
713
  readonly protocol: RouterOsProtocol;
574
714
  readonly capabilities: ProtocolAdapterCapabilities = {
@@ -577,18 +717,23 @@ class NativeApiAdapter implements ProtocolAdapter {
577
717
  inspect: true,
578
718
  };
579
719
  private session?: NativeApiSession;
720
+ private readonly onAbort = (): void => {
721
+ this.session?.close();
722
+ };
580
723
 
581
724
  constructor(private readonly config: ProtocolAdapterConfig) {
582
725
  this.protocol = config.protocol;
726
+ config.signal?.addEventListener("abort", this.onAbort, { once: true });
583
727
  }
584
728
 
585
729
  async inspect(
586
730
  request: "child" | "completion",
587
731
  path: string,
732
+ input?: string,
588
733
  ): Promise<unknown[]> {
589
734
  const replies = await this.talk({
590
735
  command: "/console/inspect",
591
- attributes: { request, path },
736
+ attributes: input === undefined ? { request, path } : { request, input },
592
737
  });
593
738
  return repliesToRecords(replies);
594
739
  }
@@ -611,6 +756,9 @@ class NativeApiAdapter implements ProtocolAdapter {
611
756
  if (options.detail) {
612
757
  command.attributes = { detail: "" };
613
758
  }
759
+ if (options.query && options.query.length > 0) {
760
+ command.queries = options.query.map((word) => `?${word}`);
761
+ }
614
762
  const replies = await this.talk(command);
615
763
  return repliesToRecords(replies);
616
764
  }
@@ -630,6 +778,9 @@ class NativeApiAdapter implements ProtocolAdapter {
630
778
  if (request.queries && request.queries.length > 0) {
631
779
  command.queries = request.queries;
632
780
  }
781
+ if (request.binary) {
782
+ command.binary = true;
783
+ }
633
784
  const replies = await this.talk(command);
634
785
  return { records: repliesToRecords(replies) };
635
786
  }
@@ -652,110 +803,60 @@ class NativeApiAdapter implements ProtocolAdapter {
652
803
  }
653
804
 
654
805
  async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
655
- const base = request.path.replace(/\/$/, "");
806
+ const records = repliesToRecords(
807
+ await this.talk(nativeCommandFor(request)),
808
+ );
656
809
  switch (request.verb) {
657
- case "print": {
658
- const command: NativeApiCommand = { command: `${base}/print` };
659
- const queries: string[] = [];
660
- // REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
661
- for (const word of request.query ?? []) {
662
- queries.push(`?${word}`);
663
- }
664
- // No native get-one-by-id shorthand: address a single row with `?.id=`.
665
- if (request.id) {
666
- queries.push(`?.id=${request.id}`);
667
- }
668
- if (queries.length > 0) {
669
- command.queries = queries;
670
- }
671
- if (request.proplist && request.proplist.length > 0) {
672
- command.proplist = request.proplist;
673
- }
674
- const records = repliesToRecords(await this.talk(command));
810
+ case "print":
675
811
  return { data: request.id ? (records[0] ?? null) : records };
676
- }
677
- case "add": {
678
- const records = repliesToRecords(
679
- await this.talk({
680
- command: `${base}/add`,
681
- attributes: request.attributes ?? {},
682
- }),
683
- );
684
- return { data: restStyleMutationData(records) };
685
- }
686
- case "set": {
687
- const records = repliesToRecords(
688
- await this.talk({
689
- command: `${base}/set`,
690
- attributes: {
691
- ...(request.attributes ?? {}),
692
- ".id": request.id ?? "",
693
- },
694
- }),
695
- );
696
- return { data: restStyleMutationData(records) };
697
- }
698
- case "remove": {
699
- const records = repliesToRecords(
700
- await this.talk({
701
- command: `${base}/remove`,
702
- attributes: { ".id": request.id ?? "" },
703
- }),
704
- );
812
+ case "add":
813
+ case "set":
814
+ case "remove":
705
815
  return { data: restStyleMutationData(records) };
706
- }
707
- case "run": {
708
- if (request.script !== undefined) {
709
- const records = await this.executeScript(request.script);
710
- return { data: restStyleRunData(records) };
711
- }
712
- const command: NativeApiCommand = { command: base };
713
- if (request.attributes && Object.keys(request.attributes).length > 0) {
714
- command.attributes = request.attributes;
715
- }
716
- return { data: repliesToRecords(await this.talk(command)) };
717
- }
816
+ case "run":
817
+ return {
818
+ data:
819
+ request.script !== undefined ? restStyleRunData(records) : records,
820
+ };
718
821
  default:
719
822
  return exhaustiveApiVerb(request.verb);
720
823
  }
721
824
  }
722
825
 
723
- async *listen(
826
+ async *stream(
724
827
  request: ProtocolApiRequest,
725
828
  options: ProtocolListenOptions,
726
- ): AsyncGenerator<Record<string, unknown>> {
727
- const base = request.path.replace(/\/$/, "");
728
- const command: NativeApiCommand = { command: `${base}/listen` };
729
- // A listen can filter which changes it follows with the same `?`-words /
730
- // `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
731
- const queries = (request.query ?? []).map((word) => `?${word}`);
732
- // An addressed row (`ip/address/*1 --stream`) follows just that row, the
733
- // same `?.id=` mapping one-shot native reads use.
734
- if (request.id) {
735
- queries.push(`?.id=${request.id}`);
736
- }
737
- if (queries.length > 0) {
738
- command.queries = queries;
739
- }
740
- if (request.proplist && request.proplist.length > 0) {
741
- command.proplist = request.proplist;
742
- }
829
+ ): AsyncGenerator<ProtocolStreamReply> {
830
+ if (options.signal?.aborted) return;
743
831
  const session = await this.connect();
744
- for await (const reply of session.listen(command, {
832
+ const { onReply } = options;
833
+ for await (const reply of session.listen(nativeCommandFor(request), {
745
834
  ...options,
746
835
  cancelGraceMs: options.cancelGraceMs ?? this.config.timeoutMs,
836
+ onReply: onReply
837
+ ? (reply) =>
838
+ onReply({
839
+ type: reply.type.slice(1) as ProtocolTappedReply["type"],
840
+ attributes: { ...reply.attributes },
841
+ })
842
+ : undefined,
747
843
  })) {
748
- // Each `!re` becomes a rest-style record (string values; `.dead` preserved).
749
- yield { ...reply.attributes };
844
+ yield {
845
+ type: reply.type === "!empty" ? "empty" : "re",
846
+ attributes: { ...reply.attributes },
847
+ ...(reply.afterStop ? { afterStop: true as const } : {}),
848
+ };
750
849
  }
751
850
  }
752
851
 
753
852
  async close(): Promise<void> {
853
+ this.config.signal?.removeEventListener("abort", this.onAbort);
754
854
  this.session?.close();
755
855
  this.session = undefined;
756
856
  }
757
857
 
758
858
  private async connect(): Promise<NativeApiSession> {
859
+ this.config.signal?.throwIfAborted();
759
860
  if (this.session) {
760
861
  return this.session;
761
862
  }
@@ -769,7 +870,12 @@ class NativeApiAdapter implements ProtocolAdapter {
769
870
  // RouterOS's self-signed cert. (Previously native-api always accepted.)
770
871
  rejectUnauthorized: this.config.insecure !== true,
771
872
  timeoutMs: this.config.timeoutMs,
873
+ signal: this.config.signal,
772
874
  });
875
+ if (this.config.signal?.aborted) {
876
+ session.close();
877
+ this.config.signal.throwIfAborted();
878
+ }
773
879
  this.session = session;
774
880
  return session;
775
881
  }
@@ -897,8 +1003,8 @@ class MacTelnetAdapter implements ProtocolAdapter {
897
1003
  }
898
1004
 
899
1005
  // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
900
- async *listen(): AsyncGenerator<Record<string, unknown>> {
901
- throw this.unsupported("open-ended listen streaming");
1006
+ async *stream(): AsyncGenerator<ProtocolStreamReply> {
1007
+ throw this.unsupported("incremental native replies");
902
1008
  }
903
1009
 
904
1010
  async close(): Promise<void> {
@@ -1048,8 +1154,8 @@ class SshExecAdapter implements ProtocolAdapter {
1048
1154
  }
1049
1155
 
1050
1156
  // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
1051
- async *listen(): AsyncGenerator<Record<string, unknown>> {
1052
- throw this.unsupported("open-ended listen streaming");
1157
+ async *stream(): AsyncGenerator<ProtocolStreamReply> {
1158
+ throw this.unsupported("incremental native replies");
1053
1159
  }
1054
1160
 
1055
1161
  close(): Promise<void> {
@@ -80,6 +80,7 @@ import {
80
80
  ecSrp5ServerPublicKey,
81
81
  ecSrp5ServerShared,
82
82
  } from "./ec-srp5.ts";
83
+ import { formatEndpoint } from "./endpoint.ts";
83
84
 
84
85
  /** Default status-exchange cadence (RouterOS uses ~1s). */
85
86
  export const BTEST_STATUS_INTERVAL_MS = 1000;
@@ -320,7 +321,7 @@ export function connectBtestControl(
320
321
  reject(
321
322
  new CentrsError({
322
323
  code: "transport/connection-refused",
323
- summary: `No btest server answered on ${host}:${port}.`,
324
+ summary: `No btest server answered on ${formatEndpoint(host, port)}.`,
324
325
  remediation:
325
326
  "Enable /tool/bandwidth-server on the device (or start `centrs btest server`) and check the host/--port.",
326
327
  context: { host, port, cause: code },
@@ -332,7 +333,7 @@ export function connectBtestControl(
332
333
  reject(
333
334
  new CentrsError({
334
335
  code: "transport/network",
335
- summary: `Failed to open the btest control connection to ${host}:${port}.`,
336
+ summary: `Failed to open the btest control connection to ${formatEndpoint(host, port)}.`,
336
337
  remediation: "Check the host, --port, and network reachability.",
337
338
  context: { host, port, cause: code },
338
339
  cause: error,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `host:port` for messages and endpoint labels. An IPv6 host is bracketed
3
+ * (`[2001:db8::5]:9`), because a bare `2001:db8::5:9` reads as another address.
4
+ * Sockets still take the bare host.
5
+ */
6
+ export function formatEndpoint(host: string, port: number): string {
7
+ const bracketed =
8
+ host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
9
+ return `${bracketed}:${port}`;
10
+ }
@@ -42,9 +42,10 @@
42
42
  * (name-level) gate — no `/console/inspect` table parsing needed.
43
43
  */
44
44
 
45
+ import { absentMenuError } from "../absent-menu.ts";
45
46
  import { parseRouterOsPosition } from "../core/routeros-errors.ts";
46
47
  import { routerOsStringLiteral } from "../core/routeros-string.ts";
47
- import { CentrsError } from "../errors.ts";
48
+ import { CentrsError, SYNTAX_REJECTED_REMEDIATION } from "../errors.ts";
48
49
  import {
49
50
  type MacAddress,
50
51
  type MacTelnetDatagramSink,
@@ -681,11 +682,13 @@ export function classifyParseResult(
681
682
  });
682
683
  }
683
684
  if (WRAPPED_BAD_COMMAND.test(output) || BARE_SYNTAX.test(output)) {
685
+ // An absent menu reads as a syntax error; name it instead (GH#361).
686
+ const absent = absentMenuError(cli, output, position, via);
687
+ if (absent) throw absent;
684
688
  throw new CentrsError({
685
689
  code: "validation/syntax",
686
690
  summary: "RouterOS rejected the command syntax while parsing it.",
687
- remediation:
688
- "Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
691
+ remediation: SYNTAX_REJECTED_REMEDIATION,
689
692
  context: {
690
693
  command: cli,
691
694
  validationSource: `:put [:parse ...] over ${via}`,