@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.
- package/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -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 +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- 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/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -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 +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -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 +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- 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 +84 -18
- 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 +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* re-couples `explain`'s browser entry to the transport graph.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { isKnownMenuPath } from "../explain/is-known-menu.ts";
|
|
17
|
+
|
|
16
18
|
export interface CanonicalExecuteCommand {
|
|
17
19
|
mode: "structured" | "script";
|
|
18
20
|
input: string;
|
|
@@ -24,8 +26,10 @@ export interface CanonicalExecuteCommand {
|
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The **script-vs-structured execution gate** — centrs's load-bearing
|
|
27
|
-
* discriminator
|
|
28
|
-
*
|
|
29
|
+
* discriminator for which validation and transport path runs. Write-shape
|
|
30
|
+
* detection is intentionally independent: a raw-script fallback must not bypass
|
|
31
|
+
* confirmation merely because it cannot be represented as attributes. centrs
|
|
32
|
+
* owns this gate; the shared,
|
|
29
33
|
* prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
|
|
30
34
|
* publish is for *canonicalization*, never the structured-mode predicate —
|
|
31
35
|
* widening what counts as `structured` is a product regression. Behavior is
|
|
@@ -96,10 +100,313 @@ export function canonicalizeExecuteCommand(
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
99
|
-
|
|
100
|
-
command.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
if (command.mode === "structured") {
|
|
104
|
+
if (FILE_OUTPUT_ATTRIBUTE in command.attributes) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
|
|
108
|
+
}
|
|
109
|
+
return scriptContainsWriteShapedCommand(command.input);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The attribute that turns an otherwise read-only verb into a device write.
|
|
114
|
+
*
|
|
115
|
+
* RouterOS routes command output to a file on the device when `file=` is
|
|
116
|
+
* supplied, so a read verb creates a file: `/ip/address/export file=address`
|
|
117
|
+
* writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
|
|
118
|
+
* from the RouterOS manual — "Configuration Export" and "Scripting Tips and
|
|
119
|
+
* Tricks", where that idiom is the documented way to create a file and needs
|
|
120
|
+
* the script `write` policy). The verb allowlist cannot see that, so the
|
|
121
|
+
* attribute is checked independently of it.
|
|
122
|
+
*/
|
|
123
|
+
const FILE_OUTPUT_ATTRIBUTE = "file";
|
|
124
|
+
|
|
125
|
+
/** True for a `file=<name>` token — the write-shaping output redirect. */
|
|
126
|
+
function isFileOutputToken(token: string): boolean {
|
|
127
|
+
return token.startsWith(`${FILE_OUTPUT_ATTRIBUTE}=`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Commands that are safe to execute without write confirmation.
|
|
132
|
+
*
|
|
133
|
+
* This is deliberately an allowlist: RouterOS publishes no mutation bit for a
|
|
134
|
+
* command, and treating a newly introduced verb as read-only would silently
|
|
135
|
+
* widen the execution boundary. False positives require `--yes`; false
|
|
136
|
+
* negatives mutate a router. API uses the same conservative rule (only
|
|
137
|
+
* print/get/listen are read-classed).
|
|
138
|
+
*/
|
|
139
|
+
const READ_ONLY_EXECUTE_VERBS: ReadonlySet<string> = new Set([
|
|
140
|
+
"check",
|
|
141
|
+
"check-installation",
|
|
142
|
+
"export",
|
|
143
|
+
"find",
|
|
144
|
+
"get",
|
|
145
|
+
"monitor",
|
|
146
|
+
"monitor-traffic",
|
|
147
|
+
"ping",
|
|
148
|
+
"print",
|
|
149
|
+
"scan",
|
|
150
|
+
"sniff",
|
|
151
|
+
"torch",
|
|
152
|
+
"traceroute",
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/** Known mutators disambiguate the space-separated CLI spelling from a menu. */
|
|
156
|
+
const KNOWN_WRITE_EXECUTE_VERBS: ReadonlySet<string> = new Set([
|
|
157
|
+
"add",
|
|
158
|
+
"comment",
|
|
159
|
+
"delete",
|
|
160
|
+
"disable",
|
|
161
|
+
"edit",
|
|
162
|
+
"enable",
|
|
163
|
+
"flush",
|
|
164
|
+
"format",
|
|
165
|
+
"format-drive",
|
|
166
|
+
"import",
|
|
167
|
+
"install",
|
|
168
|
+
"move",
|
|
169
|
+
"password",
|
|
170
|
+
"reboot",
|
|
171
|
+
"redo",
|
|
172
|
+
"remove",
|
|
173
|
+
"reset",
|
|
174
|
+
"reset-configuration",
|
|
175
|
+
"reset-counters",
|
|
176
|
+
"reset-counters-all",
|
|
177
|
+
"run",
|
|
178
|
+
"set",
|
|
179
|
+
"shutdown",
|
|
180
|
+
"sign",
|
|
181
|
+
"start",
|
|
182
|
+
"stop",
|
|
183
|
+
"unset",
|
|
184
|
+
"upgrade",
|
|
185
|
+
"undo",
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
/** Script directives that can launch a command outside the visible statement. */
|
|
189
|
+
const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
190
|
+
":execute",
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Script directives that are safe to run without device-write confirmation:
|
|
195
|
+
* output, control flow and pure value functions, all members of `explain`'s
|
|
196
|
+
* grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`). A
|
|
197
|
+
* directive's `do={…}` or `[…]` body is its own command head and is checked on
|
|
198
|
+
* its own. Deliberately absent: `:execute` and `:parse` (run built code),
|
|
199
|
+
* `:global` and `:set` (outlive the script), and `:import`, `:password`,
|
|
200
|
+
* `:undo`, `:redo` (configuration changes).
|
|
201
|
+
*/
|
|
202
|
+
const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
203
|
+
":break",
|
|
204
|
+
":continue",
|
|
205
|
+
":do",
|
|
206
|
+
":error",
|
|
207
|
+
":for",
|
|
208
|
+
":foreach",
|
|
209
|
+
":if",
|
|
210
|
+
":len",
|
|
211
|
+
":local",
|
|
212
|
+
":onerror",
|
|
213
|
+
":pick",
|
|
214
|
+
":put",
|
|
215
|
+
":return",
|
|
216
|
+
":tostr",
|
|
217
|
+
":typeof",
|
|
218
|
+
":while",
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
function scriptContainsWriteShapedCommand(input: string): boolean {
|
|
222
|
+
for (const statement of splitUnquotedStatements(input)) {
|
|
223
|
+
const tokens = tokenizeRouterOsCli(statement);
|
|
224
|
+
// `file=` redirects output to a device file, so it is a write even under a
|
|
225
|
+
// read verb. Checked before the verb rules for the same reason the
|
|
226
|
+
// structured branch checks it independently of the allowlist.
|
|
227
|
+
if (
|
|
228
|
+
tokens.some((token) => isFileOutputToken(trimGroupingPunctuation(token)))
|
|
229
|
+
) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
// A script may navigate to a menu on one line and use a relative verb on
|
|
233
|
+
// the next. Recognize grounded mutators even when no slash appears in that
|
|
234
|
+
// statement; quoted words were masked before tokenization.
|
|
235
|
+
if (
|
|
236
|
+
tokens.some((token) =>
|
|
237
|
+
KNOWN_WRITE_EXECUTE_VERBS.has(trimGroupingPunctuation(token)),
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
tokens.some((token) =>
|
|
244
|
+
WRITE_SHAPED_SCRIPT_DIRECTIVES.has(token.replace(/^[{[(]+/, "")),
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
// Every command the statement runs must be proven read-only, not just the
|
|
250
|
+
// first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
|
|
251
|
+
// reads with `find` and writes with an unlearned mutator.
|
|
252
|
+
if (!commandHeads(statement).every(isReadOnlyCommand)) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The commands a statement runs: one at its start and one after every `[` or
|
|
261
|
+
* `{`, wherever that opener sits — an argument value (`in=[/ip address find]`,
|
|
262
|
+
* `do={/user expire-password $i}`), a sigil (`$[…]`), a concatenation
|
|
263
|
+
* (`"x".[…]`) or a token of its own (`do={ /user … }`). Parentheses group
|
|
264
|
+
* expressions, not commands, so they do not start one. Each head reads only up
|
|
265
|
+
* to the next opener, which begins its own head.
|
|
266
|
+
*/
|
|
267
|
+
function commandHeads(statement: string): string[][] {
|
|
268
|
+
const heads: string[][] = [];
|
|
269
|
+
const starts = [0];
|
|
270
|
+
for (let index = 0; index < statement.length; index += 1) {
|
|
271
|
+
const char = statement[index];
|
|
272
|
+
if (char === "[" || char === "{") starts.push(index + 1);
|
|
273
|
+
}
|
|
274
|
+
for (const start of starts) {
|
|
275
|
+
const nextOpener = statement.slice(start).search(/[[{(]/);
|
|
276
|
+
const segment =
|
|
277
|
+
nextOpener < 0
|
|
278
|
+
? statement.slice(start)
|
|
279
|
+
: statement.slice(start, start + nextOpener);
|
|
280
|
+
const tokens = tokenizeRouterOsCli(segment);
|
|
281
|
+
const head = tokens[0];
|
|
282
|
+
if (head === undefined) continue;
|
|
283
|
+
const words = commandWords(head, tokens.slice(1));
|
|
284
|
+
// A lone closer (`}` ending a multi-line block) runs nothing.
|
|
285
|
+
if (words.length > 0) heads.push(words);
|
|
286
|
+
}
|
|
287
|
+
return heads;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The path segments and verb of one command, from its head token and the bare
|
|
292
|
+
* words after it. The space-separated spelling puts menu segments and the verb
|
|
293
|
+
* in those words (`/ip firewall filter print`, `/interface 6to4 print`, `/ip
|
|
294
|
+
* firewall/filter print`). A word opening a bracket starts a nested command
|
|
295
|
+
* (`new-mutator [find]`) and a word trailing a closer ends this one (`:put [/ip
|
|
296
|
+
* address find]`), so the run stops at either.
|
|
297
|
+
*/
|
|
298
|
+
function commandWords(head: string, following: readonly string[]): string[] {
|
|
299
|
+
const words: string[] = [];
|
|
300
|
+
const headWord = trimGroupingPunctuation(head);
|
|
301
|
+
if (headWord.startsWith(":")) return [headWord];
|
|
302
|
+
words.push(...headWord.split("/").filter(Boolean));
|
|
303
|
+
if (headWord !== head) return words;
|
|
304
|
+
for (const token of following) {
|
|
305
|
+
if (/^[{[(]/.test(token)) break;
|
|
306
|
+
const word = trimGroupingPunctuation(token);
|
|
307
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/.test(word))
|
|
308
|
+
break;
|
|
309
|
+
words.push(...word.split("/"));
|
|
310
|
+
if (word !== token) break;
|
|
311
|
+
}
|
|
312
|
+
return words;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A command is read-only when it is a read-only script directive, or when the
|
|
317
|
+
* first word past the longest known menu prefix is a read verb. Walking the
|
|
318
|
+
* menu table rather than taking the first known verb keeps an unlearned
|
|
319
|
+
* command from borrowing a later argument: `/user expire-password print` names
|
|
320
|
+
* `expire-password` (a published command, absent from every allowlist) with
|
|
321
|
+
* the username `print`. The menu table is a floor, so an unknown menu fails
|
|
322
|
+
* closed here, as does a run of menus with no verb (navigation for a later
|
|
323
|
+
* relative command).
|
|
324
|
+
*/
|
|
325
|
+
function isReadOnlyCommand(words: readonly string[]): boolean {
|
|
326
|
+
const head = words[0] ?? "";
|
|
327
|
+
if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
|
|
328
|
+
let depth = 0;
|
|
329
|
+
while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
|
|
330
|
+
depth += 1;
|
|
331
|
+
}
|
|
332
|
+
const verb = words[depth];
|
|
333
|
+
return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function trimGroupingPunctuation(value: string): string {
|
|
337
|
+
let start = 0;
|
|
338
|
+
let end = value.length;
|
|
339
|
+
while (start < end && "{[(".includes(value[start] ?? "")) start += 1;
|
|
340
|
+
while (end > start && "}])".includes(value[end - 1] ?? "")) end -= 1;
|
|
341
|
+
return value.slice(start, end);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Split at real statement boundaries and blank quoted text, which does not
|
|
346
|
+
* execute — except a `$[…]` or `$(…)` substitution inside a double-quoted
|
|
347
|
+
* string, which is code: `:put "$[/system identity set name=x]"` runs the set.
|
|
348
|
+
* Its body is kept (with its opener, so it becomes a command head) and may nest
|
|
349
|
+
* strings of its own. The frame grammar is `explain`'s (`stepFrame` in
|
|
350
|
+
* `src/explain/quoted-string.ts`).
|
|
351
|
+
*/
|
|
352
|
+
function splitUnquotedStatements(input: string): string[] {
|
|
353
|
+
const statements: string[] = [];
|
|
354
|
+
let current = "";
|
|
355
|
+
// `"` / `'` are string frames; `]` / `)` are the closer a code frame inside
|
|
356
|
+
// a string is waiting for. An empty stack is top-level code.
|
|
357
|
+
const frames: string[] = [];
|
|
358
|
+
let escaped = false;
|
|
359
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
360
|
+
const char = input[index] ?? "";
|
|
361
|
+
const top = frames.at(-1);
|
|
362
|
+
const inString = top === '"' || top === "'";
|
|
363
|
+
if (escaped) {
|
|
364
|
+
current += inString ? " " : char;
|
|
365
|
+
escaped = false;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (char === "\\") {
|
|
369
|
+
escaped = true;
|
|
370
|
+
current += inString ? " " : char;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (inString) {
|
|
374
|
+
if (char === top) {
|
|
375
|
+
frames.pop();
|
|
376
|
+
} else if (
|
|
377
|
+
top === '"' &&
|
|
378
|
+
char === "$" &&
|
|
379
|
+
(input[index + 1] === "[" || input[index + 1] === "(")
|
|
380
|
+
) {
|
|
381
|
+
const opener = input[index + 1] ?? "";
|
|
382
|
+
frames.push(opener === "[" ? "]" : ")");
|
|
383
|
+
current += ` ${opener}`;
|
|
384
|
+
index += 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
current += " ";
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (char === '"' || char === "'") {
|
|
391
|
+
frames.push(char);
|
|
392
|
+
current += " ";
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (top !== undefined) {
|
|
396
|
+
// Inside a string's code frame: track nesting to find its closer.
|
|
397
|
+
if (char === top) frames.pop();
|
|
398
|
+
else if (char === "[") frames.push("]");
|
|
399
|
+
else if (char === "(") frames.push(")");
|
|
400
|
+
}
|
|
401
|
+
if (char === ";" || char === "\n" || char === "\r") {
|
|
402
|
+
statements.push(current);
|
|
403
|
+
current = "";
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
current += char;
|
|
407
|
+
}
|
|
408
|
+
statements.push(current);
|
|
409
|
+
return statements;
|
|
103
410
|
}
|
|
104
411
|
|
|
105
412
|
function tokenizeRouterOsCli(input: string): string[] {
|
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"
|
|
@@ -55,12 +60,20 @@ export interface InspectChildItem {
|
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/** A row from `request=completion`: a candidate token for a path/argument. */
|
|
63
|
+
/**
|
|
64
|
+
* One `request=completion` row. These are the only fields RouterOS sends, on
|
|
65
|
+
* every build recorded from 7.9.2 to 7.25beta3 (lsp-routeros-ts
|
|
66
|
+
* `test-data/inspect-shapes.v*.json`). `text` is the candidate's help string,
|
|
67
|
+
* never its name.
|
|
68
|
+
*/
|
|
58
69
|
export interface InspectCompletionItem {
|
|
59
70
|
type?: string;
|
|
60
|
-
name?: string;
|
|
61
71
|
completion?: string;
|
|
62
|
-
|
|
72
|
+
show?: string;
|
|
73
|
+
style?: string;
|
|
63
74
|
text?: string;
|
|
75
|
+
offset?: string;
|
|
76
|
+
preference?: string;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
/**
|
|
@@ -99,31 +112,23 @@ export function isCommandNode(child: InspectChildItem, name: string): boolean {
|
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
115
|
+
* The candidate names from completion rows: `completion` of each
|
|
116
|
+
* `show: "true"` row that is not `syntax-meta`. `show: "false"` rows are
|
|
117
|
+
* syntax completions (`[`, `(`, `$`, `"`, `*`, `<value>`), not properties
|
|
118
|
+
* (CHR 7.23.7, on both `print,proplist` and `get,value-name`; #380). At an
|
|
119
|
+
* argument boundary RouterOS also offers a shown `=` ("argument value
|
|
120
|
+
* separator", `style: "syntax-meta"`) on every recorded build, 7.9.2 →
|
|
121
|
+
* 7.25beta3; no build offers a `name=value` completion. Returns
|
|
122
|
+
* the names in row order **without** de-duplication or sorting; callers that
|
|
123
|
+
* need a stable set wrap with `[...new Set(names)].sort()`.
|
|
108
124
|
*/
|
|
109
125
|
export function extractCompletionNames(
|
|
110
126
|
rows: readonly InspectCompletionItem[],
|
|
111
127
|
): string[] {
|
|
112
128
|
return rows
|
|
113
|
-
.
|
|
114
|
-
.
|
|
115
|
-
.
|
|
116
|
-
.filter((value) => value.length > 0);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The token before the first `=` in a RouterOS completion row. Plain string
|
|
121
|
-
* slicing (no regex) — `value.replace(/=.*$/, "")` triggers a CodeQL
|
|
122
|
-
* polynomial-ReDoS flag on this exported function's caller-supplied input.
|
|
123
|
-
*/
|
|
124
|
-
function nameBeforeEquals(value: string): string {
|
|
125
|
-
const eq = value.indexOf("=");
|
|
126
|
-
return eq === -1 ? value : value.slice(0, eq);
|
|
129
|
+
.filter((row) => row.show === "true" && row.style !== "syntax-meta")
|
|
130
|
+
.map((row) => row.completion?.trim() ?? "")
|
|
131
|
+
.filter((name) => name.length > 0);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
/** `request=child` for a token path. */
|
|
@@ -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
|
+
}
|