@tikoci/centrs 0.1.0 → 0.1.2

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 (45) hide show
  1. package/CHANGELOG.md +73 -2
  2. package/GLOSSARY.txt +16 -0
  3. package/README.md +1 -1
  4. package/commands/AGENTS.md +3 -2
  5. package/commands/api/AGENTS.md +212 -0
  6. package/commands/api/README.md +159 -0
  7. package/commands/api/examples.md +325 -0
  8. package/commands/btest/README.md +56 -31
  9. package/commands/btest/examples.md +35 -10
  10. package/commands/devices/README.md +26 -0
  11. package/commands/discover/README.md +17 -8
  12. package/commands/discover/examples.md +10 -5
  13. package/commands/execute/README.md +9 -3
  14. package/commands/retrieve/README.md +1 -1
  15. package/docs/CONSTITUTION.md +6 -6
  16. package/docs/MATRIX.md +46 -15
  17. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  18. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  19. package/docs/errors/usage/invalid-method.md +23 -0
  20. package/package.json +5 -5
  21. package/src/api.ts +1555 -0
  22. package/src/btest.ts +61 -1
  23. package/src/cli/api.ts +536 -0
  24. package/src/cli/btest.ts +45 -1
  25. package/src/cli/common.ts +64 -0
  26. package/src/cli/discover.ts +26 -0
  27. package/src/cli/execute.ts +30 -4
  28. package/src/cli/missing-target.ts +171 -0
  29. package/src/cli/retrieve.ts +32 -14
  30. package/src/cli/terminal.ts +18 -4
  31. package/src/cli/transfer.ts +37 -9
  32. package/src/cli.ts +22 -0
  33. package/src/core/error-catalog.ts +14 -0
  34. package/src/core/fanout.ts +160 -0
  35. package/src/core/inspect.ts +170 -0
  36. package/src/discover.ts +96 -23
  37. package/src/execute.ts +14 -47
  38. package/src/index.ts +61 -8
  39. package/src/protocols/adapter.ts +328 -0
  40. package/src/protocols/btest-session.ts +279 -46
  41. package/src/protocols/btest.ts +6 -2
  42. package/src/protocols/native-api.ts +223 -30
  43. package/src/retrieve-fanout.ts +13 -141
  44. package/src/retrieve.ts +30 -87
  45. package/commands/stream/README.md +0 -70
package/src/index.ts CHANGED
@@ -1,5 +1,38 @@
1
1
  export const centrsVersion = "0.1.0";
2
2
 
3
+ export {
4
+ type ApiEnvelope,
5
+ type ApiErrorEnvelope,
6
+ type ApiMethod,
7
+ type ApiOperationMeta,
8
+ type ApiOutputFormat,
9
+ type ApiRequest,
10
+ type ApiRequestSummary,
11
+ type ApiStreamMeta,
12
+ type ApiStreamStopReason,
13
+ type ApiStreamSummary,
14
+ type ApiSuccessEnvelope,
15
+ api,
16
+ apiEnvelope,
17
+ apiListen,
18
+ apiMethods,
19
+ apiOutputFormats,
20
+ buildApiBody,
21
+ buildApiErrorEnvelope,
22
+ buildApiErrorEnvelopeFromResolved,
23
+ buildApiQuery,
24
+ buildProtocolApiRequest,
25
+ isApiMutating,
26
+ mapMethodToVerb,
27
+ type NormalizedApiEndpoint,
28
+ normalizeApiEndpoint,
29
+ type ResolvedApiRequest,
30
+ renderApiEnvelope,
31
+ renderApiStreamLine,
32
+ resolveApiRequest,
33
+ runResolvedApi,
34
+ validateApiRequestShape,
35
+ } from "./api.ts";
3
36
  export {
4
37
  BTEST_CLIENT_CSV_HEADER,
5
38
  BTEST_SERVER_CSV_HEADER,
@@ -41,6 +74,31 @@ export {
41
74
  type SettingSourceKind,
42
75
  type Warning,
43
76
  } from "./core/envelope.ts";
77
+ export {
78
+ FANOUT_BACKOFF_BASE_MS,
79
+ FANOUT_CONCURRENCY_DEFAULTS,
80
+ FANOUT_MAX_RETRIES,
81
+ FANOUT_RETRYABLE_CODES,
82
+ isRetryableFanoutError,
83
+ resolveFanoutConcurrency,
84
+ runBoundedPool,
85
+ runWithRetry,
86
+ summarizeFanout,
87
+ } from "./core/fanout.ts";
88
+ export {
89
+ extractCompletionNames,
90
+ type InspectBackend,
91
+ type InspectChildItem,
92
+ type InspectCompletionItem,
93
+ type InspectRequestKind,
94
+ inspectChildren,
95
+ inspectChildrenOrEmpty,
96
+ inspectCompletions,
97
+ inspectPath,
98
+ isArgumentNode,
99
+ isCommandNode,
100
+ pathTokens,
101
+ } from "./core/inspect.ts";
44
102
  export {
45
103
  encodeMndpPacket,
46
104
  MNDP_BROADCAST_ADDRESS,
@@ -185,11 +243,14 @@ export {
185
243
  validateExecuteRequestShape,
186
244
  } from "./execute.ts";
187
245
  export {
246
+ type ApiVerb,
188
247
  createProtocolAdapter,
189
248
  getProtocolPlan,
190
249
  type ProtocolAdapter,
191
250
  type ProtocolAdapterCapabilities,
192
251
  type ProtocolAdapterConfig,
252
+ type ProtocolApiRequest,
253
+ type ProtocolApiResult,
193
254
  type ProtocolCapability,
194
255
  type ProtocolExecuteRequest,
195
256
  type ProtocolExecuteResult,
@@ -262,21 +323,13 @@ export {
262
323
  } from "./retrieve.ts";
263
324
  export {
264
325
  buildRetrieveFanoutErrorEnvelope,
265
- isRetryableFanoutError,
266
- RETRIEVE_FANOUT_BACKOFF_BASE_MS,
267
- RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS,
268
- RETRIEVE_FANOUT_MAX_RETRIES,
269
- RETRIEVE_FANOUT_RETRYABLE_CODES,
270
326
  type RetrieveFanoutData,
271
327
  type RetrieveFanoutEnvelope,
272
328
  type RetrieveFanoutErrorEnvelope,
273
329
  type RetrieveFanoutOperationMeta,
274
330
  type RetrieveGroupInternals,
275
331
  renderRetrieveFanoutEnvelope,
276
- resolveFanoutConcurrency,
277
332
  retrieveGroup,
278
- runBoundedPool,
279
- summarizeFanout,
280
333
  } from "./retrieve-fanout.ts";
281
334
  export {
282
335
  buildSshTerminalArgv,
@@ -106,6 +106,54 @@ export interface ProtocolExecuteResult {
106
106
  ret?: string;
107
107
  }
108
108
 
109
+ /**
110
+ * RouterOS verb the `api` orchestrator resolved from the HTTP method (the gh-api
111
+ * `-X` map: `GET`→print, `PUT`→add, `PATCH`→set, `DELETE`→remove, `POST`→run).
112
+ * `print` covers list, get-singleton, and get-one (disambiguated by `id`).
113
+ */
114
+ export type ApiVerb = "print" | "add" | "set" | "remove" | "run";
115
+
116
+ /**
117
+ * A normalized `api` request the orchestrator hands to a transport. It is
118
+ * transport-agnostic: the REST adapter maps the verb to an HTTP method + URL
119
+ * (id-in-path, `.query`/`.proplist` body), the native adapter maps it to a
120
+ * tagged `talk` (`=.id=`, `?`-prefixed queries, `=.proplist=`). The orchestrator
121
+ * owns method→verb, validation, and the write gate; the adapter only executes.
122
+ */
123
+ export interface ProtocolApiRequest {
124
+ verb: ApiVerb;
125
+ /** Slash path without id, e.g. `/ip/address`. For `run`, the command target (`/interface/monitor-traffic`); ignored when `script` is set. */
126
+ path: string;
127
+ /** Object id (`*1`) for get-one/set/remove — REST puts it in the URL, native in `=.id=`. */
128
+ id?: string;
129
+ /** Body fields (string values), the JSON object for add/set or the args for a `run` command. */
130
+ attributes?: Record<string, string>;
131
+ /** Row filter in REST `.query` form (no leading `?`), e.g. `["type=ether", "#!"]`; native prefixes each with `?`. */
132
+ query?: readonly string[];
133
+ /** Property projection → REST `.proplist` / native `=.proplist=`. */
134
+ proplist?: readonly string[];
135
+ /** Raw console line for the `/execute` script form (`run`); when set, `path`/`attributes` are ignored. */
136
+ script?: string;
137
+ }
138
+
139
+ /** Result of an `apiRequest` round-trip: the response body shaped for the envelope. */
140
+ export interface ProtocolApiResult {
141
+ /** REST returns the body verbatim (array / object / scalar); native re-maps `!re` records to the same rest-style shape. */
142
+ data: unknown;
143
+ }
144
+
145
+ /** Options for an open-ended `listen` follow. */
146
+ export interface ProtocolListenOptions {
147
+ /** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
148
+ signal?: AbortSignal;
149
+ /**
150
+ * Fired once the subscription is on the wire (connection up, listen sentence
151
+ * written) — a real "listening" barrier for callers/tests that must act only
152
+ * after the follow is established, instead of a blind timer.
153
+ */
154
+ onListening?: () => void;
155
+ }
156
+
109
157
  /**
110
158
  * Runtime transport seam. The orchestrator drives validation and data fetch
111
159
  * through these operations so REST and native-api share the same pipeline and
@@ -124,6 +172,18 @@ export interface ProtocolAdapter {
124
172
  list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
125
173
  /** Run a CLI-shaped command (WP-1c write surface). */
126
174
  execute(request: ProtocolExecuteRequest): Promise<ProtocolExecuteResult>;
175
+ /** Run a normalized structured `api` request (the gh-api passthrough surface). */
176
+ apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
177
+ /**
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`.
182
+ */
183
+ listen(
184
+ request: ProtocolApiRequest,
185
+ options: ProtocolListenOptions,
186
+ ): AsyncIterable<Record<string, unknown>>;
127
187
  /** Release any underlying connection. Safe to call when never connected. */
128
188
  close(): Promise<void>;
129
189
  }
@@ -207,10 +267,105 @@ class RestAdapter implements ProtocolAdapter {
207
267
  return normalizeRestExecute(data);
208
268
  }
209
269
 
270
+ async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
271
+ const base = request.path.replace(/\/$/, "");
272
+ switch (request.verb) {
273
+ case "print": {
274
+ const hasQuery = (request.query?.length ?? 0) > 0;
275
+ const hasProplist = (request.proplist?.length ?? 0) > 0;
276
+ if (request.id && !hasQuery && !hasProplist) {
277
+ // GET one by id: RouterOS REST addresses a single object in the URL.
278
+ return {
279
+ data: await this.requestRest("GET", `${base}/${request.id}`),
280
+ };
281
+ }
282
+ if (request.id || hasQuery || hasProplist) {
283
+ // A GET cannot carry a body, so `.query`/`.proplist` projection (and an
284
+ // id, folded into `.query` as `.id=`) rides a POST to the `/print`
285
+ // sub-endpoint — the documented REST idiom, matching native's `?`-words.
286
+ const data = await this.requestRest(
287
+ "POST",
288
+ `${base}/print`,
289
+ restQueryBody(request),
290
+ );
291
+ // An id addresses one row → unwrap to a single object, matching the
292
+ // GET-by-id shape and native's `?.id=` read.
293
+ if (request.id && Array.isArray(data)) {
294
+ return { data: data[0] ?? null };
295
+ }
296
+ return { data };
297
+ }
298
+ return { data: await this.requestRest("GET", base) };
299
+ }
300
+ case "add":
301
+ return {
302
+ data: await this.requestRest("PUT", base, request.attributes ?? {}),
303
+ };
304
+ case "set":
305
+ return {
306
+ data: await this.requestRest(
307
+ "PATCH",
308
+ `${base}/${request.id}`,
309
+ request.attributes ?? {},
310
+ ),
311
+ };
312
+ case "remove":
313
+ return {
314
+ data: await this.requestRest("DELETE", `${base}/${request.id}`),
315
+ };
316
+ case "run": {
317
+ if (request.script !== undefined) {
318
+ // Sync script run: `as-string` makes `/rest/execute` block and return the
319
+ // captured output rather than scheduling an async job (CHR-grounded).
320
+ const body = await this.requestRest("POST", "/execute", {
321
+ script: request.script,
322
+ "as-string": "",
323
+ });
324
+ const result = normalizeRestExecute(body);
325
+ return { data: result.ret ?? result.records };
326
+ }
327
+ return {
328
+ data: await this.requestRest("POST", base, request.attributes ?? {}),
329
+ };
330
+ }
331
+ default:
332
+ return exhaustiveApiVerb(request.verb);
333
+ }
334
+ }
335
+
336
+ // REST cannot hold an open-ended follow (the 60s cap); listen is native-only.
337
+ // biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
338
+ async *listen(): AsyncGenerator<Record<string, unknown>> {
339
+ throw new CentrsError({
340
+ code: "transport/capability-unsupported",
341
+ summary: "REST cannot follow an open-ended `--stream` (60s cap).",
342
+ remediation:
343
+ "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" },
345
+ });
346
+ }
347
+
210
348
  async close(): Promise<void> {
211
349
  // REST is stateless; nothing to release.
212
350
  }
213
351
 
352
+ private async requestRest(
353
+ method: string,
354
+ path: string,
355
+ body?: Record<string, unknown>,
356
+ ): Promise<unknown> {
357
+ const init: RequestInit =
358
+ body === undefined
359
+ ? { method }
360
+ : {
361
+ method,
362
+ body: JSON.stringify(body),
363
+ headers: { "Content-Type": "application/json" },
364
+ };
365
+ const response = await this.fetchRest(path, init);
366
+ return response.data;
367
+ }
368
+
214
369
  private async restGet(path: string): Promise<unknown> {
215
370
  const response = await this.fetchRest(path, { method: "GET" });
216
371
  return response.data;
@@ -472,6 +627,110 @@ class NativeApiAdapter implements ProtocolAdapter {
472
627
  return { records: repliesToRecords(replies) };
473
628
  }
474
629
 
630
+ async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
631
+ const base = request.path.replace(/\/$/, "");
632
+ switch (request.verb) {
633
+ case "print": {
634
+ const command: NativeApiCommand = { command: `${base}/print` };
635
+ const queries: string[] = [];
636
+ // REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
637
+ for (const word of request.query ?? []) {
638
+ queries.push(`?${word}`);
639
+ }
640
+ // No native get-one-by-id shorthand: address a single row with `?.id=`.
641
+ if (request.id) {
642
+ queries.push(`?.id=${request.id}`);
643
+ }
644
+ if (queries.length > 0) {
645
+ command.queries = queries;
646
+ }
647
+ if (request.proplist && request.proplist.length > 0) {
648
+ command.proplist = request.proplist;
649
+ }
650
+ const records = repliesToRecords(await this.talk(command));
651
+ return { data: request.id ? (records[0] ?? null) : records };
652
+ }
653
+ case "add": {
654
+ const records = repliesToRecords(
655
+ await this.talk({
656
+ command: `${base}/add`,
657
+ attributes: request.attributes ?? {},
658
+ }),
659
+ );
660
+ return { data: restStyleMutationData(records) };
661
+ }
662
+ case "set": {
663
+ const records = repliesToRecords(
664
+ await this.talk({
665
+ command: `${base}/set`,
666
+ attributes: {
667
+ ...(request.attributes ?? {}),
668
+ ".id": request.id ?? "",
669
+ },
670
+ }),
671
+ );
672
+ return { data: restStyleMutationData(records) };
673
+ }
674
+ case "remove": {
675
+ const records = repliesToRecords(
676
+ await this.talk({
677
+ command: `${base}/remove`,
678
+ attributes: { ".id": request.id ?? "" },
679
+ }),
680
+ );
681
+ return { data: restStyleMutationData(records) };
682
+ }
683
+ case "run": {
684
+ if (request.script !== undefined) {
685
+ // `as-string` makes `/execute` run synchronously and return the captured
686
+ // output; without it the native API schedules a job and returns its id
687
+ // (CHR-grounded — mirrors REST `/rest/execute`).
688
+ const records = repliesToRecords(
689
+ await this.talk({
690
+ command: "/execute",
691
+ attributes: { script: request.script, "as-string": "" },
692
+ }),
693
+ );
694
+ return { data: restStyleRunData(records) };
695
+ }
696
+ const command: NativeApiCommand = { command: base };
697
+ if (request.attributes && Object.keys(request.attributes).length > 0) {
698
+ command.attributes = request.attributes;
699
+ }
700
+ return { data: repliesToRecords(await this.talk(command)) };
701
+ }
702
+ default:
703
+ return exhaustiveApiVerb(request.verb);
704
+ }
705
+ }
706
+
707
+ async *listen(
708
+ request: ProtocolApiRequest,
709
+ options: ProtocolListenOptions,
710
+ ): AsyncGenerator<Record<string, unknown>> {
711
+ const base = request.path.replace(/\/$/, "");
712
+ const command: NativeApiCommand = { command: `${base}/listen` };
713
+ // A listen can filter which changes it follows with the same `?`-words /
714
+ // `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
715
+ const queries = (request.query ?? []).map((word) => `?${word}`);
716
+ // An addressed row (`ip/address/*1 --stream`) follows just that row, the
717
+ // same `?.id=` mapping one-shot native reads use.
718
+ if (request.id) {
719
+ queries.push(`?.id=${request.id}`);
720
+ }
721
+ if (queries.length > 0) {
722
+ command.queries = queries;
723
+ }
724
+ if (request.proplist && request.proplist.length > 0) {
725
+ command.proplist = request.proplist;
726
+ }
727
+ const session = await this.connect();
728
+ for await (const reply of session.listen(command, options)) {
729
+ // Each `!re` becomes a rest-style record (string values; `.dead` preserved).
730
+ yield { ...reply.attributes };
731
+ }
732
+ }
733
+
475
734
  async close(): Promise<void> {
476
735
  this.session?.close();
477
736
  this.session = undefined;
@@ -614,6 +873,15 @@ class MacTelnetAdapter implements ProtocolAdapter {
614
873
  return { records: [], ret: output };
615
874
  }
616
875
 
876
+ apiRequest(): Promise<ProtocolApiResult> {
877
+ return Promise.reject(this.unsupported("structured api requests"));
878
+ }
879
+
880
+ // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
881
+ async *listen(): AsyncGenerator<Record<string, unknown>> {
882
+ throw this.unsupported("open-ended listen streaming");
883
+ }
884
+
617
885
  async close(): Promise<void> {
618
886
  this.console?.close();
619
887
  this.transport?.close();
@@ -756,11 +1024,71 @@ class SshExecAdapter implements ProtocolAdapter {
756
1024
  return { records: [], ret: output };
757
1025
  }
758
1026
 
1027
+ apiRequest(): Promise<ProtocolApiResult> {
1028
+ return Promise.reject(this.unsupported("structured api requests"));
1029
+ }
1030
+
1031
+ // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
1032
+ async *listen(): AsyncGenerator<Record<string, unknown>> {
1033
+ throw this.unsupported("open-ended listen streaming");
1034
+ }
1035
+
759
1036
  close(): Promise<void> {
760
1037
  return Promise.resolve();
761
1038
  }
762
1039
  }
763
1040
 
1041
+ /** REST `.query` / `.proplist` projection body for a POST `/print`. */
1042
+ function restQueryBody(request: ProtocolApiRequest): Record<string, unknown> {
1043
+ const body: Record<string, unknown> = {};
1044
+ // Fold an id into `.query` as `.id=<id>` so id+query/proplist reads work over
1045
+ // REST exactly as they do over native (`?.id=` plus the `?`-words).
1046
+ const query = [
1047
+ ...(request.id ? [`.id=${request.id}`] : []),
1048
+ ...(request.query ?? []),
1049
+ ];
1050
+ if (query.length > 0) {
1051
+ body[".query"] = query;
1052
+ }
1053
+ if (request.proplist && request.proplist.length > 0) {
1054
+ body[".proplist"] = request.proplist;
1055
+ }
1056
+ return body;
1057
+ }
1058
+
1059
+ /**
1060
+ * Re-map a native add/set/remove reply to the rest-style shape. `add` returns the
1061
+ * new id in `=ret=`; surface it as `{".id": ret}` (REST returns the created
1062
+ * object with `.id`). `set`/`remove` reply with a bare `!done` (no records) → no
1063
+ * meaningful body, mirrored as `null`.
1064
+ */
1065
+ function restStyleMutationData(
1066
+ records: readonly Record<string, string>[],
1067
+ ): unknown {
1068
+ if (records.length === 0) {
1069
+ return null;
1070
+ }
1071
+ const record = records[0] ?? {};
1072
+ const ret = record["ret"];
1073
+ if (typeof ret === "string" && /^\*[0-9A-F]+$/i.test(ret)) {
1074
+ return { ".id": ret };
1075
+ }
1076
+ return records.length === 1 ? record : records;
1077
+ }
1078
+
1079
+ /** Re-map a native `/execute` reply: prefer the captured `ret` string, else the records. */
1080
+ function restStyleRunData(records: readonly Record<string, string>[]): unknown {
1081
+ const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
1082
+ if (typeof ret === "string") {
1083
+ return ret;
1084
+ }
1085
+ return records.length === 1 ? records[0] : records;
1086
+ }
1087
+
1088
+ function exhaustiveApiVerb(verb: never): never {
1089
+ throw new Error(`Unhandled api verb: ${String(verb)}`);
1090
+ }
1091
+
764
1092
  function normalizeRestExecute(data: unknown): ProtocolExecuteResult {
765
1093
  if (Array.isArray(data)) {
766
1094
  return { records: data.filter(isPlainObject) as Record<string, string>[] };