@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.
- package/CHANGELOG.md +256 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +123 -41
- package/commands/api/examples.md +205 -17
- package/commands/devices/README.md +11 -3
- package/commands/execute/README.md +31 -3
- package/commands/execute/examples.md +136 -1
- package/commands/explain/README.md +30 -4
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +422 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +28 -20
- package/docs/CONSTITUTION.md +30 -10
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +511 -186
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +223 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +56 -1
- package/src/core/inspect.ts +43 -5
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +1 -1
- package/src/devices.ts +6 -9
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +13 -8
- package/src/execute.ts +106 -65
- package/src/explain/catalog.ts +58 -10
- package/src/explain/menus.ts +23 -2
- package/src/explain.ts +6 -6
- package/src/index.ts +29 -1
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +14 -17
- package/src/protocols/adapter.ts +224 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +6 -3
- package/src/protocols/native-api.ts +223 -70
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +53 -7
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +732 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
package/src/core/inspect.ts
CHANGED
|
@@ -30,16 +30,21 @@ import { CentrsError } from "../errors.ts";
|
|
|
30
30
|
* protocol layer (no import cycle).
|
|
31
31
|
*/
|
|
32
32
|
export interface InspectBackend {
|
|
33
|
-
inspect(
|
|
33
|
+
inspect(
|
|
34
|
+
request: "child" | "completion",
|
|
35
|
+
path: string,
|
|
36
|
+
input?: string,
|
|
37
|
+
): Promise<unknown[]>;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
41
|
* The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
|
|
38
42
|
* and `completion` are wired today (the two the {@link InspectBackend} seam
|
|
39
|
-
* exposes)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
+
* exposes), plus `input` for completion over CLI text
|
|
44
|
+
* ({@link inspectWhereBooleans}); `highlight` (token-level error detection),
|
|
45
|
+
* `syntax` (parse tree) and the `.query`/`.proplist` request fields are the
|
|
46
|
+
* documented extension point for value-level validation — widen the adapter
|
|
47
|
+
* seam when adding them.
|
|
43
48
|
*/
|
|
44
49
|
export type InspectRequestKind =
|
|
45
50
|
| "child"
|
|
@@ -200,3 +205,36 @@ export async function inspectChildrenOrEmpty(
|
|
|
200
205
|
throw error;
|
|
201
206
|
}
|
|
202
207
|
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Which of `names` are booleans in `<menu>/print where`, asked of the device:
|
|
211
|
+
* completion after `where <name>=` offers exactly `no` and `yes` as `arg` rows
|
|
212
|
+
* for a boolean (`disabled`, `dynamic`, `running`), and `none`-styled values
|
|
213
|
+
* or `<value>` otherwise (enums such as `protocol` or `arp`, strings, numbers;
|
|
214
|
+
* CHR 7.24.5, #397). A `--query` bare name needs this: RouterOS reads
|
|
215
|
+
* `where disabled` as `disabled=yes` but `where comment` as "comment is set",
|
|
216
|
+
* and only the device knows which a property is. One request per name.
|
|
217
|
+
*/
|
|
218
|
+
export async function inspectWhereBooleans(
|
|
219
|
+
backend: InspectBackend,
|
|
220
|
+
menuPath: string,
|
|
221
|
+
names: readonly string[],
|
|
222
|
+
): Promise<Set<string>> {
|
|
223
|
+
const booleans = new Set<string>();
|
|
224
|
+
const menu = menuPath.replace(/\/$/, "");
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
const rows = (await backend.inspect(
|
|
227
|
+
"completion",
|
|
228
|
+
"",
|
|
229
|
+
`${menu}/print where ${name}=`,
|
|
230
|
+
)) as InspectCompletionItem[];
|
|
231
|
+
const values = rows
|
|
232
|
+
.filter((row) => row.style !== "syntax-meta")
|
|
233
|
+
.map((row) => `${row.style}:${row.completion}`)
|
|
234
|
+
.sort();
|
|
235
|
+
if (values.join(" ") === "arg:no arg:yes") {
|
|
236
|
+
booleans.add(name);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return booleans;
|
|
240
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -326,7 +326,7 @@ export function mapRouterOsError(
|
|
|
326
326
|
? `RouterOS reported an error: ${trimmed}`
|
|
327
327
|
: "RouterOS reported an unspecified error.",
|
|
328
328
|
remediation:
|
|
329
|
-
"
|
|
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.",
|
|
330
330
|
context: { ...baseContext, ...opts.context },
|
|
331
331
|
...(position ? { position } : {}),
|
|
332
332
|
causeData: raw,
|
package/src/devices.ts
CHANGED
|
@@ -33,7 +33,11 @@ import {
|
|
|
33
33
|
writeWinBoxCdb,
|
|
34
34
|
} from "./data/winbox-cdb-write.ts";
|
|
35
35
|
import { parseDeviceRecord } from "./devices-schema.ts";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
CentrsError,
|
|
38
|
+
formatCentrsErrorText,
|
|
39
|
+
serializeCentrsError,
|
|
40
|
+
} from "./errors.ts";
|
|
37
41
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
38
42
|
import {
|
|
39
43
|
applyCommentKv,
|
|
@@ -1841,14 +1845,7 @@ function renderYamlInline(value: unknown, indent: string): string {
|
|
|
1841
1845
|
function renderText(envelope: DevicesEnvelope<unknown>): string {
|
|
1842
1846
|
const lines: string[] = [];
|
|
1843
1847
|
if (!envelope.ok) {
|
|
1844
|
-
|
|
1845
|
-
lines.push(`[${error.code}] ${error.summary}`);
|
|
1846
|
-
if (error.remediation) {
|
|
1847
|
-
lines.push(`Fix: ${error.remediation}`);
|
|
1848
|
-
}
|
|
1849
|
-
if (error.detailsUrl) {
|
|
1850
|
-
lines.push(`Details: ${error.detailsUrl}`);
|
|
1851
|
-
}
|
|
1848
|
+
lines.push(formatCentrsErrorText(envelope.error));
|
|
1852
1849
|
appendWarnings(lines, envelope.warnings);
|
|
1853
1850
|
appendTips(lines, envelope.tips);
|
|
1854
1851
|
return lines.join("\n");
|
package/src/discover.ts
CHANGED
|
@@ -37,7 +37,12 @@ import {
|
|
|
37
37
|
loadCdb,
|
|
38
38
|
resolveDevicesSettings,
|
|
39
39
|
} from "./devices.ts";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
asCentrsError,
|
|
42
|
+
CentrsError,
|
|
43
|
+
formatCentrsErrorText,
|
|
44
|
+
serializeCentrsError,
|
|
45
|
+
} from "./errors.ts";
|
|
41
46
|
import { parseCommentKv, renderCommentKvToken } from "./resolver/comment-kv.ts";
|
|
42
47
|
import { normalizeMac } from "./resolver/mac.ts";
|
|
43
48
|
|
|
@@ -771,13 +776,7 @@ export function renderDiscoverEnvelope(
|
|
|
771
776
|
function renderDiscoverText(envelope: DiscoverEnvelope): string {
|
|
772
777
|
const lines: string[] = [];
|
|
773
778
|
if (!envelope.ok) {
|
|
774
|
-
lines.push(
|
|
775
|
-
if (envelope.error.remediation) {
|
|
776
|
-
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
777
|
-
}
|
|
778
|
-
if (envelope.error.detailsUrl) {
|
|
779
|
-
lines.push(`Details: ${envelope.error.detailsUrl}`);
|
|
780
|
-
}
|
|
779
|
+
lines.push(formatCentrsErrorText(envelope.error));
|
|
781
780
|
appendDiscoverWarnings(lines, envelope.warnings);
|
|
782
781
|
return lines.join("\n");
|
|
783
782
|
}
|