@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
@@ -0,0 +1,508 @@
1
+ /**
2
+ * `--query` predicates: the inside of a RouterOS `print where …`, compiled to
3
+ * the API query stack (native `?` words, REST `.query` — the same words minus
4
+ * the `?`, `commands/api/AGENTS.md`).
5
+ *
6
+ * One grammar serves `retrieve --query`, `api --query` and, later, `--until`
7
+ * (#397, #364). It reads like `where`: `interface=ether1 and !disabled`,
8
+ * `(type=ether or type=vlan) and mtu>=1500`. The device evaluates the compiled
9
+ * stack, so comparisons keep RouterOS's per-property typing.
10
+ *
11
+ * Words are emitted in postfix order; `#!`, `#&`, `#|` pop their operands, and
12
+ * whatever remains is AND-ed by RouterOS. Mapping, grounded by the CHR
13
+ * `find where` vs `.query` differential (`test/integration/retrieve-query.test.ts`):
14
+ *
15
+ * | where | words |
16
+ * | -------------- | ----------------------------- |
17
+ * | `a=v` | `a=v` |
18
+ * | `a!=v` | `a=v` `#!` |
19
+ * | `a<v` | `<a=v` `-a` `#|` |
20
+ * | `a>v` | `>a=v` |
21
+ * | `a<=v` | `>a=v` `#!` |
22
+ * | `a>=v` | `<a=v` `-a` `#|` `#!` |
23
+ * | bare `a` | `a=yes` for a boolean, else `a` (is set) |
24
+ * | `!x` | x `#!` |
25
+ * | `x and y` | x y `#&` |
26
+ * | `x or y` | x y `#|` |
27
+ *
28
+ * Ordering follows `where`, where a row without the property is smaller than
29
+ * any value: `timeout<1d` includes static address-list entries, `timeout>1h`
30
+ * does not. API `<`/`>` skip such rows, hence the `-a` (absent) word.
31
+ *
32
+ * A bare name means what it means after `where`, which depends on the
33
+ * property: `where disabled` is `disabled=yes`, `where comment` is "comment is
34
+ * set" (an empty comment is stored as absent). The caller asks the device
35
+ * which names are booleans (`inspectWhereBooleans`) before emitting.
36
+ *
37
+ * Refused rather than approximated: `~` (regex) and `in` (prefix membership)
38
+ * have no API query word; `$`/`[`/`{` values need a script evaluator;
39
+ * `!a=v` is `(!a)=v` to RouterOS, which matches by accident, so it must be
40
+ * written `!(a=v)` or `a!=v`; `not` is not a RouterOS operator; and
41
+ * space-separated conditions must be joined with `and`/`or`.
42
+ *
43
+ * The device decides comparisons with the API's per-property typing, which is
44
+ * not always the CLI's: on CHR 7.23.7/7.24.5, `where dst-port=80` matches
45
+ * nothing, while the query matches the `"80"` that `retrieve` shows. On CHR
46
+ * 7.25beta3/7.25rc1/7.26beta1 the CLI agrees with the query; ordering
47
+ * on a text property (`dst-port`, address-list `address`) is text order.
48
+ */
49
+
50
+ import { CentrsError } from "../errors.ts";
51
+
52
+ export type QueryComparison = "=" | "!=" | "<" | ">" | "<=" | ">=";
53
+
54
+ export type QueryNode =
55
+ | { kind: "compare"; name: string; op: QueryComparison; value: string }
56
+ | { kind: "truthy"; name: string }
57
+ | { kind: "not"; operand: QueryNode }
58
+ | { kind: "and" | "or"; left: QueryNode; right: QueryNode };
59
+
60
+ export interface ParsedQuery {
61
+ /** One tree per `--query`; several are AND-ed. */
62
+ nodes: QueryNode[];
63
+ /** Every property the predicate reads, for inspect validation. */
64
+ names: string[];
65
+ /** Properties used bare (`disabled`, `!comment`), whose type decides the word. */
66
+ bareNames: string[];
67
+ }
68
+
69
+ type Token =
70
+ | { kind: "name"; text: string; at: number }
71
+ | { kind: "op"; text: QueryComparison | "~" | "in"; at: number }
72
+ | { kind: "value"; text: string; at: number }
73
+ | { kind: "and" | "or" | "not" | "(" | ")"; at: number };
74
+
75
+ const NAME = /^\.?[A-Za-z][A-Za-z0-9.-]*/;
76
+ const BARE_VALUE_STOP = new Set([" ", "\t", "\r", "\n", "(", ")", '"']);
77
+ /**
78
+ * RouterOS string escapes, the device's set (`src/explain/quoted-string.ts`
79
+ * has the sweep). Hex is two UPPERCASE digits: `\ff` is `\f` then `f`.
80
+ */
81
+ const ESCAPES: Record<string, string> = {
82
+ '"': '"',
83
+ "\\": "\\",
84
+ $: "$",
85
+ "?": "?",
86
+ _: " ",
87
+ a: "\x07",
88
+ b: "\b",
89
+ f: "\f",
90
+ n: "\n",
91
+ r: "\r",
92
+ t: "\t",
93
+ v: "\v",
94
+ };
95
+ const HEX_ESCAPE = /^[0-9A-F]{2}$/;
96
+
97
+ /** Parse one or more `--query` expressions offline. */
98
+ export function parseQueries(
99
+ expressions: readonly string[],
100
+ flag: "--query" | "--until" = "--query",
101
+ ): ParsedQuery {
102
+ let nodes: QueryNode[];
103
+ try {
104
+ nodes = expressions.map(parseQuery);
105
+ } catch (error) {
106
+ if (flag === "--until" && error instanceof CentrsError)
107
+ throw queryErrorForFlag(error, flag);
108
+ throw error;
109
+ }
110
+ const names = new Set<string>();
111
+ const bareNames = new Set<string>();
112
+ const visit = (node: QueryNode): void => {
113
+ switch (node.kind) {
114
+ case "truthy":
115
+ names.add(node.name);
116
+ bareNames.add(node.name);
117
+ return;
118
+ case "compare":
119
+ names.add(node.name);
120
+ return;
121
+ case "not":
122
+ visit(node.operand);
123
+ return;
124
+ default:
125
+ visit(node.left);
126
+ visit(node.right);
127
+ }
128
+ };
129
+ nodes.forEach(visit);
130
+ return { nodes, names: [...names], bareNames: [...bareNames] };
131
+ }
132
+
133
+ /** Keep shared grammar diagnostics attached to the caller's predicate flag. */
134
+ export function queryErrorForFlag(
135
+ error: CentrsError,
136
+ flag: "--until",
137
+ ): CentrsError {
138
+ return new CentrsError({
139
+ code: error.code,
140
+ summary: error.summary.replace("--query", flag),
141
+ remediation: error.remediation?.replace("--query", flag),
142
+ context: { ...error.context, flag },
143
+ position: error.position,
144
+ cause: error.cause,
145
+ causeData: error.causeData,
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Query words (no `?` prefix) in stack order. `booleans` names the bare
151
+ * properties the device reports as booleans; any other bare name means "is set".
152
+ */
153
+ export function compileQueryWords(
154
+ parsed: ParsedQuery,
155
+ booleans: ReadonlySet<string>,
156
+ ): string[] {
157
+ const words: string[] = [];
158
+ parsed.nodes.forEach((node, index) => {
159
+ emit(node, words, booleans);
160
+ if (index > 0) words.push("#&");
161
+ });
162
+ return words;
163
+ }
164
+
165
+ export function parseQuery(expression: string): QueryNode {
166
+ const tokens = tokenize(expression);
167
+ let position = 0;
168
+ const peek = () => tokens[position];
169
+
170
+ const fail = (summary: string, at: number): never => {
171
+ throw invalidQuery(expression, summary, at);
172
+ };
173
+
174
+ const parseOr = (): QueryNode => {
175
+ let left = parseAnd();
176
+ while (peek()?.kind === "or") {
177
+ position++;
178
+ left = { kind: "or", left, right: parseAnd() };
179
+ }
180
+ return left;
181
+ };
182
+
183
+ const parseAnd = (): QueryNode => {
184
+ let left = parseUnary();
185
+ while (peek()?.kind === "and") {
186
+ position++;
187
+ left = { kind: "and", left, right: parseUnary() };
188
+ }
189
+ return left;
190
+ };
191
+
192
+ const parseUnary = (): QueryNode => {
193
+ const token = peek();
194
+ if (token === undefined) {
195
+ return fail(
196
+ "The expression ends where a condition was expected.",
197
+ expression.length,
198
+ );
199
+ }
200
+ if (token.kind === "not") {
201
+ position++;
202
+ const next = tokens[position];
203
+ if (next?.kind === "name" && tokens[position + 1]?.kind === "op") {
204
+ return fail(
205
+ `RouterOS reads \`!${next.text}…\` as \`(!${next.text})…\`; write \`!(${next.text}…)\` or use \`!=\`.`,
206
+ token.at,
207
+ );
208
+ }
209
+ return { kind: "not", operand: parseUnary() };
210
+ }
211
+ if (token.kind === "(") {
212
+ position++;
213
+ const inner = parseOr();
214
+ const close = peek();
215
+ if (close?.kind !== ")") {
216
+ return fail("A `(` is never closed.", token.at);
217
+ }
218
+ position++;
219
+ return inner;
220
+ }
221
+ if (token.kind !== "name") {
222
+ return fail("Expected a property name.", token.at);
223
+ }
224
+ if (token.text === "not") {
225
+ return fail("RouterOS has no `not` operator; use `!`.", token.at);
226
+ }
227
+ position++;
228
+ const op = peek();
229
+ if (op?.kind !== "op") {
230
+ return { kind: "truthy", name: token.text };
231
+ }
232
+ position++;
233
+ if (op.text === "~" || op.text === "in") {
234
+ throw unsupportedOperator(expression, op.text, op.at);
235
+ }
236
+ const value = peek();
237
+ if (value?.kind !== "value") {
238
+ return fail(`\`${op.text}\` needs a value after it.`, op.at);
239
+ }
240
+ position++;
241
+ return {
242
+ kind: "compare",
243
+ name: token.text,
244
+ op: op.text,
245
+ value: value.text,
246
+ };
247
+ };
248
+
249
+ const node = parseOr();
250
+ const rest = peek();
251
+ if (rest !== undefined) {
252
+ fail(
253
+ rest.kind === ")"
254
+ ? "A `)` has no matching `(`."
255
+ : "Join conditions with `and` or `or`.",
256
+ rest.at,
257
+ );
258
+ }
259
+ return node;
260
+ }
261
+
262
+ function emit(
263
+ node: QueryNode,
264
+ words: string[],
265
+ booleans: ReadonlySet<string>,
266
+ ): void {
267
+ switch (node.kind) {
268
+ case "truthy":
269
+ words.push(booleans.has(node.name) ? `${node.name}=yes` : node.name);
270
+ return;
271
+ case "compare": {
272
+ const { name, value } = node;
273
+ switch (node.op) {
274
+ case "=":
275
+ words.push(`${name}=${value}`);
276
+ return;
277
+ case "!=":
278
+ words.push(`${name}=${value}`, "#!");
279
+ return;
280
+ // `where` counts a row without the property as smaller than any
281
+ // value; API `<`/`>` skip such rows, so `<` adds them back (`-a`).
282
+ case "<":
283
+ words.push(`<${name}=${value}`, `-${name}`, "#|");
284
+ return;
285
+ case ">":
286
+ words.push(`>${name}=${value}`);
287
+ return;
288
+ case "<=":
289
+ words.push(`>${name}=${value}`, "#!");
290
+ return;
291
+ case ">=":
292
+ words.push(`<${name}=${value}`, `-${name}`, "#|", "#!");
293
+ return;
294
+ }
295
+ return;
296
+ }
297
+ case "not":
298
+ emit(node.operand, words, booleans);
299
+ words.push("#!");
300
+ return;
301
+ case "and":
302
+ case "or":
303
+ emit(node.left, words, booleans);
304
+ emit(node.right, words, booleans);
305
+ words.push(node.kind === "and" ? "#&" : "#|");
306
+ return;
307
+ }
308
+ }
309
+
310
+ function tokenize(expression: string): Token[] {
311
+ const tokens: Token[] = [];
312
+ let index = 0;
313
+ // After a comparison operator the next token is a value, never a name.
314
+ let wantValue = false;
315
+ while (index < expression.length) {
316
+ const char = expression[index] as string;
317
+ if (/\s/.test(char)) {
318
+ // RouterOS: `comment= x` is "expected comment value". `in` is a word,
319
+ // so a space follows it.
320
+ const last = tokens.at(-1);
321
+ if (wantValue && !(last?.kind === "op" && last.text === "in")) {
322
+ throw invalidQuery(
323
+ expression,
324
+ "A value must follow its operator without a space; quote a value that starts with one.",
325
+ index,
326
+ );
327
+ }
328
+ index++;
329
+ continue;
330
+ }
331
+ const at = index;
332
+ if (wantValue) {
333
+ wantValue = false;
334
+ if (char === '"') {
335
+ const { text, end } = readQuoted(expression, index);
336
+ tokens.push({ kind: "value", text, at });
337
+ index = end;
338
+ continue;
339
+ }
340
+ let end = index;
341
+ while (
342
+ end < expression.length &&
343
+ !BARE_VALUE_STOP.has(expression[end] as string)
344
+ ) {
345
+ end++;
346
+ }
347
+ const text = expression.slice(index, end);
348
+ if (text.length === 0) {
349
+ break;
350
+ }
351
+ rejectScriptValue(expression, text, at);
352
+ // RouterOS chains it: `comment=x=yes` is `(comment=x)=yes`, and
353
+ // `comment=x!=y` (or `<=`, `>=`) matches nothing.
354
+ const chained = text.indexOf("=");
355
+ if (chained >= 0) {
356
+ throw invalidQuery(
357
+ expression,
358
+ `RouterOS reads a second \`=\` as another comparison; quote the value: ${expression.slice(tokens.at(-2)?.at ?? 0, at)}"${text}".`,
359
+ at + chained,
360
+ );
361
+ }
362
+ tokens.push({ kind: "value", text, at });
363
+ index = end;
364
+ continue;
365
+ }
366
+ const two = expression.slice(index, index + 2);
367
+ if (two === "&&" || two === "||") {
368
+ tokens.push({ kind: two === "&&" ? "and" : "or", at });
369
+ index += 2;
370
+ continue;
371
+ }
372
+ if (two === "!=" || two === "<=" || two === ">=") {
373
+ tokens.push({ kind: "op", text: two, at });
374
+ index += 2;
375
+ wantValue = true;
376
+ continue;
377
+ }
378
+ if (char === "=" || char === "<" || char === ">" || char === "~") {
379
+ tokens.push({ kind: "op", text: char, at });
380
+ index++;
381
+ wantValue = true;
382
+ continue;
383
+ }
384
+ if (char === "!" || char === "(" || char === ")") {
385
+ tokens.push({ kind: char === "!" ? "not" : char, at });
386
+ index++;
387
+ continue;
388
+ }
389
+ const name = NAME.exec(expression.slice(index))?.[0];
390
+ if (name === undefined) {
391
+ throw invalidQuery(
392
+ expression,
393
+ `Unexpected \`${char}\`; expected a property name, \`!\` or \`(\`.`,
394
+ at,
395
+ );
396
+ }
397
+ if (name === "and" || name === "or") {
398
+ tokens.push({ kind: name, at });
399
+ } else if (name === "in") {
400
+ tokens.push({ kind: "op", text: "in", at });
401
+ wantValue = true;
402
+ } else {
403
+ tokens.push({ kind: "name", text: name, at });
404
+ }
405
+ index += name.length;
406
+ }
407
+ return tokens;
408
+ }
409
+
410
+ function readQuoted(
411
+ expression: string,
412
+ start: number,
413
+ ): { text: string; end: number } {
414
+ let text = "";
415
+ let index = start + 1;
416
+ while (index < expression.length) {
417
+ const char = expression[index] as string;
418
+ if (char === '"') {
419
+ return { text, end: index + 1 };
420
+ }
421
+ if (char === "$") {
422
+ throw scriptValue(expression, start);
423
+ }
424
+ if (char !== "\\") {
425
+ text += char;
426
+ index++;
427
+ continue;
428
+ }
429
+ const next = expression[index + 1] ?? "";
430
+ const hex = expression.slice(index + 1, index + 3);
431
+ if (next in ESCAPES) {
432
+ text += ESCAPES[next];
433
+ index += 2;
434
+ } else if (/[ \t\r\n]/.test(next)) {
435
+ // A backslash before whitespace joins the lines: it and the whole
436
+ // whitespace run after it are dropped (`"x\<LF> y"` is `xy`).
437
+ index++;
438
+ while (/[ \t\r\n]/.test(expression[index] ?? "")) index++;
439
+ } else if (HEX_ESCAPE.test(hex)) {
440
+ const byte = Number.parseInt(hex, 16);
441
+ if (byte > 0x7f) {
442
+ // RouterOS stores the raw byte; centrs sends values as UTF-8.
443
+ throw invalidQuery(
444
+ expression,
445
+ `\`\\${hex}\` is a byte above 7F, which a query value cannot carry; write the character itself.`,
446
+ index,
447
+ );
448
+ }
449
+ text += String.fromCharCode(byte);
450
+ index += 3;
451
+ } else {
452
+ throw invalidQuery(
453
+ expression,
454
+ `\`\\${next}\` is not a RouterOS string escape.`,
455
+ index,
456
+ );
457
+ }
458
+ }
459
+ throw invalidQuery(expression, "A quoted value is never closed.", start);
460
+ }
461
+
462
+ function rejectScriptValue(expression: string, text: string, at: number): void {
463
+ if (text.startsWith("$") || text.startsWith("[") || text.startsWith("{")) {
464
+ throw scriptValue(expression, at);
465
+ }
466
+ }
467
+
468
+ function invalidQuery(
469
+ expression: string,
470
+ summary: string,
471
+ at: number,
472
+ ): CentrsError {
473
+ return new CentrsError({
474
+ code: "input/invalid-query",
475
+ summary: `Invalid --query: ${summary}`,
476
+ remediation:
477
+ 'Write the query as you would after `print where`: `name=ether1`, `mtu>=1500 and !disabled`, `(type=ether or type=vlan)`. Quote values with spaces: `comment="uplink a"`.',
478
+ context: { query: expression, offset: at },
479
+ });
480
+ }
481
+
482
+ function unsupportedOperator(
483
+ expression: string,
484
+ operator: "~" | "in",
485
+ at: number,
486
+ ): CentrsError {
487
+ return new CentrsError({
488
+ code: "input/unsupported-query",
489
+ summary:
490
+ operator === "~"
491
+ ? "`~` (regex) has no RouterOS API query word, so --query cannot run it on the router."
492
+ : "`in` (prefix membership) has no RouterOS API query word, so --query cannot run it on the router.",
493
+ remediation:
494
+ "Read the rows without the condition and filter them yourself, or run the console form with `centrs execute <router> '<menu>/print where …'` (console text, not rows).",
495
+ context: { query: expression, operator, offset: at },
496
+ });
497
+ }
498
+
499
+ function scriptValue(expression: string, at: number): CentrsError {
500
+ return new CentrsError({
501
+ code: "input/unsupported-query",
502
+ summary:
503
+ "--query values are literal: `$variables`, `[commands]` and `{arrays}` need RouterOS's script evaluator.",
504
+ remediation:
505
+ "Put the literal value in the query (escape a `$` inside quotes as `\\$`), or run the console form with `centrs execute`.",
506
+ context: { query: expression, offset: at },
507
+ });
508
+ }
@@ -88,6 +88,12 @@ export interface RouterOsErrorRule {
88
88
  description: string;
89
89
  /** Pattern matched against the trimmed RouterOS string. */
90
90
  test: RegExp;
91
+ /**
92
+ * Anchored form safe for classifying a successful transport's returned text.
93
+ * Unlike an HTTP error body / native trap, `/execute as-string` also carries
94
+ * ordinary stdout, so substring matches would turn user output into failures.
95
+ */
96
+ resultTest?: RegExp;
91
97
  /** Builds the normalized error fields from the regex match and raw string. */
92
98
  build: (match: RegExpMatchArray, raw: string) => RouterOsErrorRuleResult;
93
99
  }
@@ -106,6 +112,8 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
106
112
  description:
107
113
  "RouterOS did not recognize the command path or menu item ('no such ...').",
108
114
  test: /no such command prefix|no such item|no such entry/i,
115
+ resultTest:
116
+ /^(?:failure:\s*)?(?:no such command prefix|no such item|no such entry)\b.*$/i,
109
117
  build: (_match, raw) => ({
110
118
  summary: `RouterOS does not recognize the path: ${raw.trim()}`,
111
119
  remediation:
@@ -119,6 +127,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
119
127
  // (mac-telnet) says "bad parameter <name> (line N column M)" for the same
120
128
  // fault. Grounded on CHR 7.23.1.
121
129
  test: /(?:unknown|bad) parameter\s+(\S+)/i,
130
+ resultTest: /^(?:failure:\s*)?(?:unknown|bad) parameter\s+\S+.*$/i,
122
131
  build: (match) => {
123
132
  const parameter = cleanToken(match[1] ?? "");
124
133
  return {
@@ -135,6 +144,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
135
144
  "RouterOS console rejected the command word ('bad command name …').",
136
145
  // Console form (mac-telnet) of an unrecognized command/path word.
137
146
  test: /bad command name/i,
147
+ resultTest: /^(?:failure:\s*)?(?:\(<%%\s*)?bad command name\b.*$/i,
138
148
  build: (_match, raw) => ({
139
149
  summary: `RouterOS does not recognize the command: ${raw.trim()}`,
140
150
  remediation:
@@ -145,6 +155,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
145
155
  code: "routeros/invalid-value",
146
156
  description: "RouterOS rejected the value supplied for an argument.",
147
157
  test: /invalid value (?:for argument|of)\s+(\S+)/i,
158
+ resultTest: /^(?:failure:\s*)?invalid value (?:for argument|of)\s+\S+.*$/i,
148
159
  build: (match) => {
149
160
  const argument = cleanToken(match[1] ?? "");
150
161
  return {
@@ -155,11 +166,24 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
155
166
  };
156
167
  },
157
168
  },
169
+ {
170
+ code: "routeros/invalid-value",
171
+ description:
172
+ "RouterOS rejected a value because it does not match the accepted domain.",
173
+ test: /input does not match/i,
174
+ resultTest: /^(?:failure:\s*)?input does not match\b.*$/i,
175
+ build: (_match, raw) => ({
176
+ summary: `RouterOS rejected the supplied value: ${raw.trim()}`,
177
+ remediation:
178
+ "Check the accepted values for this command on the target RouterOS version, then retry with a matching value.",
179
+ }),
180
+ },
158
181
  {
159
182
  code: "routeros/session-closed",
160
183
  description:
161
184
  "RouterOS closed the REST session before the request completed (60-second cap).",
162
185
  test: /session closed/i,
186
+ resultTest: /^(?:failure:\s*)?session closed\b.*$/i,
163
187
  build: () => ({
164
188
  summary: "RouterOS closed the session before the request completed.",
165
189
  remediation:
@@ -171,6 +195,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
171
195
  description:
172
196
  "Generic RouterOS command failure carrying a `failure: <msg>` string.",
173
197
  test: /^\s*failure:\s*(.+?)\s*$/i,
198
+ resultTest: /^\s*failure:\s*.+?\s*$/i,
174
199
  build: (match) => {
175
200
  const failure = (match[1] ?? "").trim();
176
201
  return {
@@ -181,11 +206,27 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
181
206
  };
182
207
  },
183
208
  },
209
+ {
210
+ code: "routeros/command-failed",
211
+ description: "Generic RouterOS command failure carrying an error prefix.",
212
+ test: /^\s*error:\s*(.+?)\s*$/i,
213
+ resultTest: /^\s*error:\s*.+?\s*$/i,
214
+ build: (match) => {
215
+ const failure = (match[1] ?? "").trim();
216
+ return {
217
+ summary: `RouterOS command failed: ${failure}`,
218
+ remediation:
219
+ "Inspect the RouterOS error message, then adjust the script or request shape accordingly.",
220
+ context: { failure },
221
+ };
222
+ },
223
+ },
184
224
  {
185
225
  code: "routeros/command-failed",
186
226
  description:
187
227
  "RouterOS /execute as-string returned a script :error value with source location.",
188
228
  test: /^\s*(.+?)\s*\(:error; line \d+\)\s*$/i,
229
+ resultTest: /^\s*.+?\s*\(:error; line \d+\)\s*$/i,
189
230
  build: (match) => {
190
231
  const failure = (match[1] ?? "").trim();
191
232
  return {
@@ -203,6 +244,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
203
244
  code: "routeros/unknown-path",
204
245
  description: "RouterOS reported a path-shaped 'not found'.",
205
246
  test: /not found/i,
247
+ resultTest: /^\s*\/\S+\s+not found\s*$/i,
206
248
  build: (_match, raw) => ({
207
249
  summary: `RouterOS does not recognize the path: ${raw.trim()}`,
208
250
  remediation:
@@ -211,6 +253,30 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
211
253
  },
212
254
  ];
213
255
 
256
+ /**
257
+ * Grounded result shapes that intentionally use the transport-specific
258
+ * catch-all mapping. They are errors, but do not carry enough information for a
259
+ * more specific normalized code.
260
+ */
261
+ const ROUTEROS_RESULT_CATCH_ALL =
262
+ /^(?:syntax error|expected (?:end of command|command name|input value))\b[^\n]*$/i;
263
+
264
+ /**
265
+ * Map text returned by a successful transport only when it matches a grounded,
266
+ * stdout-safe result shape. Returns `undefined` for ordinary script output.
267
+ */
268
+ export function mapRouterOsResultError(
269
+ raw: string,
270
+ opts: MapRouterOsErrorOptions = {},
271
+ ): CentrsError | undefined {
272
+ const trimmed = raw.trim();
273
+ if (trimmed.length === 0) return undefined;
274
+ const matched =
275
+ ROUTEROS_RESULT_CATCH_ALL.test(trimmed) ||
276
+ routerOsErrorRules.some((rule) => rule.resultTest?.test(trimmed));
277
+ return matched ? mapRouterOsError(raw, opts) : undefined;
278
+ }
279
+
214
280
  /**
215
281
  * Maps a raw RouterOS error string to a normalized {@link CentrsError}.
216
282
  *
@@ -260,7 +326,7 @@ export function mapRouterOsError(
260
326
  ? `RouterOS reported an error: ${trimmed}`
261
327
  : "RouterOS reported an unspecified error.",
262
328
  remediation:
263
- "Inspect the original RouterOS message; the command word, path, or an attribute is likely invalid.",
329
+ "RouterOS refused the request, and its message is the reason. If the message names part of the input (a path, parameter or value), fix that; otherwise the router may be busy or the operation unavailable right now.",
264
330
  context: { ...baseContext, ...opts.context },
265
331
  ...(position ? { position } : {}),
266
332
  causeData: raw,