@tikoci/centrs 0.1.6 → 0.1.8-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  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 +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -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 +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. 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;
@@ -152,6 +190,29 @@ export interface ProtocolListenOptions {
152
190
  * after the follow is established, instead of a blind timer.
153
191
  */
154
192
  onListening?: () => void;
193
+ /**
194
+ * How long to wait for RouterOS to acknowledge `/cancel` after `signal`
195
+ * aborts before ending the listen locally. Native API defaults to the
196
+ * adapter's `timeoutMs`, the same bound as any other reply wait.
197
+ */
198
+ cancelGraceMs?: number;
199
+ /** Fired when that wait expires without the router's acknowledgement. */
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>;
155
216
  }
156
217
 
157
218
  /**
@@ -165,9 +226,14 @@ export interface ProtocolAdapter {
165
226
  /** Capability flags describing what this adapter supports. */
166
227
  readonly capabilities: ProtocolAdapterCapabilities;
167
228
  /** `/console/inspect` probe (`request=child` or `request=completion`). */
168
- 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[]>;
169
235
  /** Read a single record (singleton menu) as an object. */
170
- getSingleton(path: string): Promise<unknown>;
236
+ getSingleton(path: string, options?: RetrieveReadOptions): Promise<unknown>;
171
237
  /** Read a menu as an array of records, optionally projected/detailed. */
172
238
  list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
173
239
  /** Run a CLI-shaped command (WP-1c write surface). */
@@ -175,15 +241,16 @@ export interface ProtocolAdapter {
175
241
  /** Run a normalized structured `api` request (the gh-api passthrough surface). */
176
242
  apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
177
243
  /**
178
- * Open-ended `/listen` follow (native-api only). Yields one rest-style change
179
- * record per `!re` frame (deletions carry `.dead`), until cancelled via
180
- * `options.signal` or the consumer stopping. REST and console transports
181
- * 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`.
182
249
  */
183
- listen(
250
+ stream(
184
251
  request: ProtocolApiRequest,
185
252
  options: ProtocolListenOptions,
186
- ): AsyncIterable<Record<string, unknown>>;
253
+ ): AsyncIterable<ProtocolStreamReply>;
187
254
  /** Release any underlying connection. Safe to call when never connected. */
188
255
  close(): Promise<void>;
189
256
  }
@@ -228,28 +295,48 @@ class RestAdapter implements ProtocolAdapter {
228
295
  async inspect(
229
296
  request: "child" | "completion",
230
297
  path: string,
298
+ input?: string,
231
299
  ): Promise<unknown[]> {
232
- return this.restPost<unknown[]>("/console/inspect", { request, path });
300
+ return this.restPost<unknown[]>(
301
+ "/console/inspect",
302
+ input === undefined ? { request, path } : { request, input },
303
+ );
233
304
  }
234
305
 
235
- async getSingleton(path: string): Promise<unknown> {
236
- return this.restGet(path);
306
+ async getSingleton(
307
+ path: string,
308
+ options: RetrieveReadOptions = {},
309
+ ): Promise<unknown> {
310
+ return this.restGet(path, options.signal);
237
311
  }
238
312
 
239
313
  async list(path: string, options: RetrieveListOptions): Promise<unknown[]> {
240
- const hasProjection =
241
- (options.proplist?.length ?? 0) > 0 || options.detail === true;
242
- if (!hasProjection) {
243
- 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[];
244
320
  }
245
- const body: { ".proplist"?: readonly string[]; detail?: string } = {};
321
+ const body: {
322
+ ".proplist"?: readonly string[];
323
+ ".query"?: readonly string[];
324
+ detail?: string;
325
+ } = {};
246
326
  if (options.proplist && options.proplist.length > 0) {
247
327
  body[".proplist"] = options.proplist;
248
328
  }
329
+ if (options.query && options.query.length > 0) {
330
+ body[".query"] = options.query;
331
+ }
249
332
  if (options.detail) {
250
333
  body.detail = "true";
251
334
  }
252
- return this.restPost<unknown[]>(`${path.replace(/\/$/, "")}/print`, body);
335
+ return this.restPost<unknown[]>(
336
+ `${path.replace(/\/$/, "")}/print`,
337
+ body,
338
+ options.signal,
339
+ );
253
340
  }
254
341
 
255
342
  async execute(
@@ -263,6 +350,21 @@ class RestAdapter implements ProtocolAdapter {
263
350
  return normalizeRestExecute(data);
264
351
  }
265
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
+ }
266
368
  const data = await this.restPost<unknown>(target, request.attributes ?? {});
267
369
  return normalizeRestExecute(data);
268
370
  }
@@ -325,7 +427,10 @@ class RestAdapter implements ProtocolAdapter {
325
427
  return { data: result.ret ?? result.records };
326
428
  }
327
429
  return {
328
- data: await this.requestRest("POST", base, request.attributes ?? {}),
430
+ data: await this.requestRest("POST", base, {
431
+ ...request.attributes,
432
+ ...restQueryBody(request),
433
+ }),
329
434
  };
330
435
  }
331
436
  default:
@@ -333,15 +438,15 @@ class RestAdapter implements ProtocolAdapter {
333
438
  }
334
439
  }
335
440
 
336
- // 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.
337
442
  // biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
338
- async *listen(): AsyncGenerator<Record<string, unknown>> {
443
+ async *stream(): AsyncGenerator<ProtocolStreamReply> {
339
444
  throw new CentrsError({
340
445
  code: "transport/capability-unsupported",
341
446
  summary: "REST cannot follow an open-ended `--stream` (60s cap).",
342
447
  remediation:
343
448
  "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
344
- context: { protocol: this.protocol, capability: "listen" },
449
+ context: { protocol: this.protocol, capability: "stream" },
345
450
  });
346
451
  }
347
452
 
@@ -366,19 +471,21 @@ class RestAdapter implements ProtocolAdapter {
366
471
  return response.data;
367
472
  }
368
473
 
369
- private async restGet(path: string): Promise<unknown> {
370
- 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 });
371
476
  return response.data;
372
477
  }
373
478
 
374
479
  private async restPost<T = unknown>(
375
480
  path: string,
376
481
  body: Record<string, unknown>,
482
+ signal?: AbortSignal,
377
483
  ): Promise<T> {
378
484
  const response = await this.fetchRest(path, {
379
485
  method: "POST",
380
486
  body: JSON.stringify(body),
381
487
  headers: { "Content-Type": "application/json" },
488
+ signal,
382
489
  });
383
490
  return response.data as T;
384
491
  }
@@ -386,6 +493,7 @@ class RestAdapter implements ProtocolAdapter {
386
493
  private async fetchRest(
387
494
  path: string,
388
495
  init: RequestInit,
496
+ binary = false,
389
497
  ): Promise<RestResponse> {
390
498
  const url = joinRestUrl(this.config.baseUrl, path);
391
499
  const headers = new Headers(init.headers);
@@ -410,13 +518,25 @@ class RestAdapter implements ProtocolAdapter {
410
518
  } = {
411
519
  ...init,
412
520
  headers,
413
- 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
+ ]),
414
529
  tls: { rejectUnauthorized: this.config.insecure !== true },
415
530
  };
416
531
 
417
532
  try {
418
533
  const response = await fetch(url, fetchInit);
419
- 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
+ );
420
540
  const data = parseResponseBody(text);
421
541
  if (!response.ok) {
422
542
  throw this.mapHttpFailure(response.status, text, data, path);
@@ -443,7 +563,7 @@ class RestAdapter implements ProtocolAdapter {
443
563
  if (status === 401 || status === 403) {
444
564
  return new CentrsError({
445
565
  code: "transport/auth-failed",
446
- summary: `RouterOS rejected the REST credentials for ${host}:${port}.`,
566
+ summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
447
567
  remediation:
448
568
  "Check `--username` / `--password` or the matching `CENTRS_*` environment variables, and confirm the user has RouterOS REST access.",
449
569
  context: { via: protocol, host, port, path, status },
@@ -489,7 +609,7 @@ class RestAdapter implements ProtocolAdapter {
489
609
  ) {
490
610
  return new CentrsError({
491
611
  code: "transport/timeout",
492
- summary: `Timed out waiting for ${protocol} to respond from ${host}:${port}.`,
612
+ summary: `Timed out waiting for ${protocol} to respond from ${formatEndpoint(host, port)}.`,
493
613
  remediation:
494
614
  "Increase `--timeout` within the REST ceiling, or confirm the host and port are reachable.",
495
615
  context: { via: protocol, host, port, path, timeoutMs },
@@ -510,7 +630,7 @@ class RestAdapter implements ProtocolAdapter {
510
630
  ) {
511
631
  return new CentrsError({
512
632
  code: "transport/connection-refused",
513
- summary: `Connection refused by ${host}:${port} over ${protocol}.`,
633
+ summary: `Connection refused by ${formatEndpoint(host, port)} over ${protocol}.`,
514
634
  remediation:
515
635
  "Check that the RouterOS REST service is enabled on that port and that any local forwarding or firewall rules are correct.",
516
636
  context: { via: protocol, host, port, path },
@@ -542,7 +662,7 @@ class RestAdapter implements ProtocolAdapter {
542
662
  if (messages.some((candidate) => candidate.includes("certificate"))) {
543
663
  return new CentrsError({
544
664
  code: "transport/tls-certificate",
545
- summary: `TLS certificate validation failed for ${host}:${port}.`,
665
+ summary: `TLS certificate validation failed for ${formatEndpoint(host, port)}.`,
546
666
  remediation:
547
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.",
548
668
  context: { via: protocol, host, port, path },
@@ -552,7 +672,7 @@ class RestAdapter implements ProtocolAdapter {
552
672
 
553
673
  return new CentrsError({
554
674
  code: "transport/network",
555
- summary: `Network request to ${host}:${port} failed over ${protocol}.`,
675
+ summary: `Network request to ${formatEndpoint(host, port)} failed over ${protocol}.`,
556
676
  remediation:
557
677
  "Check the host, port, and service availability, then re-run with `--format json` if you need the structured cause data.",
558
678
  context: { via: protocol, host, port, path },
@@ -561,6 +681,34 @@ class RestAdapter implements ProtocolAdapter {
561
681
  }
562
682
  }
563
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
+
564
712
  class NativeApiAdapter implements ProtocolAdapter {
565
713
  readonly protocol: RouterOsProtocol;
566
714
  readonly capabilities: ProtocolAdapterCapabilities = {
@@ -569,18 +717,23 @@ class NativeApiAdapter implements ProtocolAdapter {
569
717
  inspect: true,
570
718
  };
571
719
  private session?: NativeApiSession;
720
+ private readonly onAbort = (): void => {
721
+ this.session?.close();
722
+ };
572
723
 
573
724
  constructor(private readonly config: ProtocolAdapterConfig) {
574
725
  this.protocol = config.protocol;
726
+ config.signal?.addEventListener("abort", this.onAbort, { once: true });
575
727
  }
576
728
 
577
729
  async inspect(
578
730
  request: "child" | "completion",
579
731
  path: string,
732
+ input?: string,
580
733
  ): Promise<unknown[]> {
581
734
  const replies = await this.talk({
582
735
  command: "/console/inspect",
583
- attributes: { request, path },
736
+ attributes: input === undefined ? { request, path } : { request, input },
584
737
  });
585
738
  return repliesToRecords(replies);
586
739
  }
@@ -603,6 +756,9 @@ class NativeApiAdapter implements ProtocolAdapter {
603
756
  if (options.detail) {
604
757
  command.attributes = { detail: "" };
605
758
  }
759
+ if (options.query && options.query.length > 0) {
760
+ command.queries = options.query.map((word) => `?${word}`);
761
+ }
606
762
  const replies = await this.talk(command);
607
763
  return repliesToRecords(replies);
608
764
  }
@@ -622,6 +778,9 @@ class NativeApiAdapter implements ProtocolAdapter {
622
778
  if (request.queries && request.queries.length > 0) {
623
779
  command.queries = request.queries;
624
780
  }
781
+ if (request.binary) {
782
+ command.binary = true;
783
+ }
625
784
  const replies = await this.talk(command);
626
785
  return { records: repliesToRecords(replies) };
627
786
  }
@@ -644,107 +803,60 @@ class NativeApiAdapter implements ProtocolAdapter {
644
803
  }
645
804
 
646
805
  async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
647
- const base = request.path.replace(/\/$/, "");
806
+ const records = repliesToRecords(
807
+ await this.talk(nativeCommandFor(request)),
808
+ );
648
809
  switch (request.verb) {
649
- case "print": {
650
- const command: NativeApiCommand = { command: `${base}/print` };
651
- const queries: string[] = [];
652
- // REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
653
- for (const word of request.query ?? []) {
654
- queries.push(`?${word}`);
655
- }
656
- // No native get-one-by-id shorthand: address a single row with `?.id=`.
657
- if (request.id) {
658
- queries.push(`?.id=${request.id}`);
659
- }
660
- if (queries.length > 0) {
661
- command.queries = queries;
662
- }
663
- if (request.proplist && request.proplist.length > 0) {
664
- command.proplist = request.proplist;
665
- }
666
- const records = repliesToRecords(await this.talk(command));
810
+ case "print":
667
811
  return { data: request.id ? (records[0] ?? null) : records };
668
- }
669
- case "add": {
670
- const records = repliesToRecords(
671
- await this.talk({
672
- command: `${base}/add`,
673
- attributes: request.attributes ?? {},
674
- }),
675
- );
676
- return { data: restStyleMutationData(records) };
677
- }
678
- case "set": {
679
- const records = repliesToRecords(
680
- await this.talk({
681
- command: `${base}/set`,
682
- attributes: {
683
- ...(request.attributes ?? {}),
684
- ".id": request.id ?? "",
685
- },
686
- }),
687
- );
688
- return { data: restStyleMutationData(records) };
689
- }
690
- case "remove": {
691
- const records = repliesToRecords(
692
- await this.talk({
693
- command: `${base}/remove`,
694
- attributes: { ".id": request.id ?? "" },
695
- }),
696
- );
812
+ case "add":
813
+ case "set":
814
+ case "remove":
697
815
  return { data: restStyleMutationData(records) };
698
- }
699
- case "run": {
700
- if (request.script !== undefined) {
701
- const records = await this.executeScript(request.script);
702
- return { data: restStyleRunData(records) };
703
- }
704
- const command: NativeApiCommand = { command: base };
705
- if (request.attributes && Object.keys(request.attributes).length > 0) {
706
- command.attributes = request.attributes;
707
- }
708
- return { data: repliesToRecords(await this.talk(command)) };
709
- }
816
+ case "run":
817
+ return {
818
+ data:
819
+ request.script !== undefined ? restStyleRunData(records) : records,
820
+ };
710
821
  default:
711
822
  return exhaustiveApiVerb(request.verb);
712
823
  }
713
824
  }
714
825
 
715
- async *listen(
826
+ async *stream(
716
827
  request: ProtocolApiRequest,
717
828
  options: ProtocolListenOptions,
718
- ): AsyncGenerator<Record<string, unknown>> {
719
- const base = request.path.replace(/\/$/, "");
720
- const command: NativeApiCommand = { command: `${base}/listen` };
721
- // A listen can filter which changes it follows with the same `?`-words /
722
- // `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
723
- const queries = (request.query ?? []).map((word) => `?${word}`);
724
- // An addressed row (`ip/address/*1 --stream`) follows just that row, the
725
- // same `?.id=` mapping one-shot native reads use.
726
- if (request.id) {
727
- queries.push(`?.id=${request.id}`);
728
- }
729
- if (queries.length > 0) {
730
- command.queries = queries;
731
- }
732
- if (request.proplist && request.proplist.length > 0) {
733
- command.proplist = request.proplist;
734
- }
829
+ ): AsyncGenerator<ProtocolStreamReply> {
830
+ if (options.signal?.aborted) return;
735
831
  const session = await this.connect();
736
- for await (const reply of session.listen(command, options)) {
737
- // Each `!re` becomes a rest-style record (string values; `.dead` preserved).
738
- yield { ...reply.attributes };
832
+ const { onReply } = options;
833
+ for await (const reply of session.listen(nativeCommandFor(request), {
834
+ ...options,
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,
843
+ })) {
844
+ yield {
845
+ type: reply.type === "!empty" ? "empty" : "re",
846
+ attributes: { ...reply.attributes },
847
+ ...(reply.afterStop ? { afterStop: true as const } : {}),
848
+ };
739
849
  }
740
850
  }
741
851
 
742
852
  async close(): Promise<void> {
853
+ this.config.signal?.removeEventListener("abort", this.onAbort);
743
854
  this.session?.close();
744
855
  this.session = undefined;
745
856
  }
746
857
 
747
858
  private async connect(): Promise<NativeApiSession> {
859
+ this.config.signal?.throwIfAborted();
748
860
  if (this.session) {
749
861
  return this.session;
750
862
  }
@@ -758,7 +870,12 @@ class NativeApiAdapter implements ProtocolAdapter {
758
870
  // RouterOS's self-signed cert. (Previously native-api always accepted.)
759
871
  rejectUnauthorized: this.config.insecure !== true,
760
872
  timeoutMs: this.config.timeoutMs,
873
+ signal: this.config.signal,
761
874
  });
875
+ if (this.config.signal?.aborted) {
876
+ session.close();
877
+ this.config.signal.throwIfAborted();
878
+ }
762
879
  this.session = session;
763
880
  return session;
764
881
  }
@@ -886,8 +1003,8 @@ class MacTelnetAdapter implements ProtocolAdapter {
886
1003
  }
887
1004
 
888
1005
  // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
889
- async *listen(): AsyncGenerator<Record<string, unknown>> {
890
- throw this.unsupported("open-ended listen streaming");
1006
+ async *stream(): AsyncGenerator<ProtocolStreamReply> {
1007
+ throw this.unsupported("incremental native replies");
891
1008
  }
892
1009
 
893
1010
  async close(): Promise<void> {
@@ -1037,8 +1154,8 @@ class SshExecAdapter implements ProtocolAdapter {
1037
1154
  }
1038
1155
 
1039
1156
  // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
1040
- async *listen(): AsyncGenerator<Record<string, unknown>> {
1041
- throw this.unsupported("open-ended listen streaming");
1157
+ async *stream(): AsyncGenerator<ProtocolStreamReply> {
1158
+ throw this.unsupported("incremental native replies");
1042
1159
  }
1043
1160
 
1044
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
+ }