@tikoci/centrs 0.1.8 → 0.1.9-next.22
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 +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +33 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/core/envelope.ts
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - `meta` is invariant across commands. Only `data` and `meta.operation` vary
|
|
16
16
|
* per command. Do not add command-specific keys beside `target`/`via`/
|
|
17
17
|
* `settings`/`validation`/`timing`/`truncated`; put them in `meta.operation`.
|
|
18
|
+
* - Shared meta is written once (#461, #462, #473). A fan-out target omits
|
|
19
|
+
* what its outer envelope already says ({@link FanoutMemberOperation}); a
|
|
20
|
+
* stream's data lines between `started` and `summary` carry only their
|
|
21
|
+
* `meta.operation` ({@link StreamLineEnvelope}).
|
|
18
22
|
* - CLI render must be lossless against the JSON envelope; `--format json|yaml`
|
|
19
23
|
* select serialization, not different shapes. Omit `undefined`; use `null`
|
|
20
24
|
* only where absence is meaningful and must round-trip (e.g. `via: null` when
|
|
@@ -109,6 +113,17 @@ export interface EnvelopeTargetMeta {
|
|
|
109
113
|
source?: SettingSource;
|
|
110
114
|
/** Provenance per resolved field once the resolver is wired (WP-0c). */
|
|
111
115
|
sources?: Record<string, SettingSource>;
|
|
116
|
+
/**
|
|
117
|
+
* `execute --cmr`: the CMR client this result belongs to, as the controller
|
|
118
|
+
* knows it. `id` is the controller's `/cmr/device` row; `deviceKey` is the
|
|
119
|
+
* device half of its `ids`. The client is never dialed directly; the
|
|
120
|
+
* controller is the fan-out's outer `meta.target`.
|
|
121
|
+
*/
|
|
122
|
+
cmr?: {
|
|
123
|
+
id: string;
|
|
124
|
+
deviceKey?: string;
|
|
125
|
+
address?: string;
|
|
126
|
+
};
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
/**
|
|
@@ -153,6 +168,11 @@ export interface ValidationTrace {
|
|
|
153
168
|
* to judge.
|
|
154
169
|
*/
|
|
155
170
|
offlineVerdict?: "pass" | "warn";
|
|
171
|
+
/**
|
|
172
|
+
* Set immediately before the RouterOS request is sent. A failure after this
|
|
173
|
+
* point may have run on the router, so a write must not be replayed (#448).
|
|
174
|
+
*/
|
|
175
|
+
dispatched?: boolean;
|
|
156
176
|
}
|
|
157
177
|
|
|
158
178
|
export interface EnvelopeValidationMeta {
|
|
@@ -271,9 +291,34 @@ export interface FanoutSummary {
|
|
|
271
291
|
failed: number;
|
|
272
292
|
}
|
|
273
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A fan-out target's `meta.operation`. `request` and `auth` are omitted when
|
|
296
|
+
* they equal the outer `meta.operation`'s, and the target's `meta.settings`
|
|
297
|
+
* keeps only the keys whose source differs from the outer `meta.settings`, so
|
|
298
|
+
* a reader takes `target ?? outer` (per key for settings).
|
|
299
|
+
*/
|
|
300
|
+
export type FanoutMemberOperation<Operation> = Omit<
|
|
301
|
+
Operation,
|
|
302
|
+
"request" | "auth"
|
|
303
|
+
> &
|
|
304
|
+
Partial<Pick<Operation, Extract<keyof Operation, "request" | "auth">>>;
|
|
305
|
+
|
|
274
306
|
export interface FanoutData<Data = unknown, Operation = unknown> {
|
|
275
307
|
summary: FanoutSummary;
|
|
276
|
-
targets: readonly CentrsEnvelope<Data, Operation
|
|
308
|
+
targets: readonly CentrsEnvelope<Data, FanoutMemberOperation<Operation>>[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A stream data line that carries only its own `meta.operation`; the rest of
|
|
313
|
+
* its meta is on the stream's `started` and `summary` lines (and `synced` for
|
|
314
|
+
* `retrieve --follow`).
|
|
315
|
+
*/
|
|
316
|
+
export interface StreamLineEnvelope<Data = unknown, Operation = unknown> {
|
|
317
|
+
ok: true;
|
|
318
|
+
data: Data;
|
|
319
|
+
warnings: readonly Warning[];
|
|
320
|
+
tips: readonly Tip[];
|
|
321
|
+
meta: { operation: Operation };
|
|
277
322
|
}
|
|
278
323
|
|
|
279
324
|
/** Normalize an optional warnings list to a readonly array. */
|
|
@@ -55,11 +55,13 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
code: "cdb/empty-group",
|
|
58
|
-
summary:
|
|
58
|
+
summary:
|
|
59
|
+
"The named CDB group contains no records: a warning for a read, an error for a write-shaped fan-out.",
|
|
59
60
|
},
|
|
60
61
|
{
|
|
61
62
|
code: "cdb/empty-selection",
|
|
62
|
-
summary:
|
|
63
|
+
summary:
|
|
64
|
+
"The target selection matched no CDB records: a warning for a read, an error for a write-shaped fan-out.",
|
|
63
65
|
},
|
|
64
66
|
{
|
|
65
67
|
code: "cdb/file-exists",
|
|
@@ -262,6 +264,83 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
262
264
|
summary: "A received MNDP datagram could not be decoded.",
|
|
263
265
|
},
|
|
264
266
|
|
|
267
|
+
// cmr/* — `execute --cmr` (#450)
|
|
268
|
+
{
|
|
269
|
+
code: "cmr/cancelled",
|
|
270
|
+
summary:
|
|
271
|
+
"The operation was cancelled before the command was sent to a CMR client.",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
code: "cmr/caller-policy-not-enforced",
|
|
275
|
+
summary:
|
|
276
|
+
"Warning: the controller login lacks a policy the CMR command runs with on the clients, and RouterOS does not check it.",
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
code: "cmr/client-script-not-validated",
|
|
280
|
+
summary:
|
|
281
|
+
"Warning: a caller-addressed `/cmr/device/run-script` was validated as a controller request; its script was not validated on the clients.",
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
code: "cmr/client-disconnected",
|
|
285
|
+
summary:
|
|
286
|
+
"CMR skipped a client that is not connected to its controller; the command was not sent.",
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
code: "cmr/empty-selection",
|
|
290
|
+
summary:
|
|
291
|
+
"The CMR selection matched no device on the controller: for `--cmr`, a warning for a read and an error for a write; for a fleet command such as `/cmr/vlan/add`, a warning.",
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
code: "cmr/membership-changed",
|
|
295
|
+
summary:
|
|
296
|
+
"A CMR client left the label selection or changed its key between validation and dispatch; the command was not sent.",
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
code: "cmr/not-a-controller",
|
|
300
|
+
summary:
|
|
301
|
+
"The target does not answer `/cmr/device`; it is not a CMR controller.",
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
code: "cmr/console-only",
|
|
305
|
+
summary:
|
|
306
|
+
"The CMR command is an interactive console view that breaks an API session, so centrs refused it before running it.",
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
code: "cmr/outcome-unknown",
|
|
310
|
+
summary:
|
|
311
|
+
"The command was sent to a CMR client but no final status came back; it may still be running.",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
code: "cmr/output-truncated",
|
|
315
|
+
summary:
|
|
316
|
+
"Warning: CMR clipped a client's command output at its ~60 KiB cap.",
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
code: "cmr/policy-denied",
|
|
320
|
+
summary:
|
|
321
|
+
"A CMR client refused a statement because CMR scripts run without the policy it needs; earlier statements already ran.",
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
code: "cmr/rule-selects-every-device",
|
|
325
|
+
summary:
|
|
326
|
+
"Warning: a CMR rule command has no device `labels` (and, for VLAN, no `port-labels`), so the rule applies to every device, the controller included.",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
code: "cmr/selection-includes-controller",
|
|
330
|
+
summary:
|
|
331
|
+
"Warning: a CMR fleet command selects the controller's own row as well as its clients.",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
code: "cmr/selection-incomplete",
|
|
335
|
+
summary:
|
|
336
|
+
"The controller did not answer whether every device matches the labels, so nothing was run.",
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
code: "cmr/validation-incomplete",
|
|
340
|
+
summary:
|
|
341
|
+
"A CMR client's validation result was incomplete or clipped, so the command was not sent to it.",
|
|
342
|
+
},
|
|
343
|
+
|
|
265
344
|
// quickchr/*
|
|
266
345
|
{
|
|
267
346
|
code: "quickchr/package-unavailable",
|
|
@@ -533,6 +612,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
533
612
|
code: "transport/unsupported-operation",
|
|
534
613
|
summary: "The chosen transport does not support this operation.",
|
|
535
614
|
},
|
|
615
|
+
{
|
|
616
|
+
code: "transport/write-outcome-unknown",
|
|
617
|
+
summary:
|
|
618
|
+
"Warning: a write failed after it was sent, so it may have run on the router; centrs did not retry it.",
|
|
619
|
+
},
|
|
536
620
|
|
|
537
621
|
// usage/*
|
|
538
622
|
{
|
|
@@ -544,6 +628,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
544
628
|
code: "usage/conflicting-flags",
|
|
545
629
|
summary: "Two or more supplied flags conflict.",
|
|
546
630
|
},
|
|
631
|
+
{
|
|
632
|
+
code: "usage/flag-after-separator",
|
|
633
|
+
summary:
|
|
634
|
+
"A centrs flag was written after `--`, where every token is RouterOS input.",
|
|
635
|
+
},
|
|
547
636
|
{
|
|
548
637
|
code: "usage/invalid-concurrency",
|
|
549
638
|
summary: "The --concurrency value must be an integer >= 1.",
|
|
@@ -567,6 +656,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
567
656
|
code: "usage/invalid-method",
|
|
568
657
|
summary: "The -X/--method value is not a supported HTTP method.",
|
|
569
658
|
},
|
|
659
|
+
{
|
|
660
|
+
code: "usage/missing-labels",
|
|
661
|
+
summary: "`--cmr` was given without a `--labels` expression.",
|
|
662
|
+
},
|
|
570
663
|
{
|
|
571
664
|
code: "usage/missing-group",
|
|
572
665
|
summary: "The operation requires a non-empty --group value.",
|
|
@@ -101,7 +101,7 @@ export function canonicalizeExecuteCommand(
|
|
|
101
101
|
|
|
102
102
|
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
103
103
|
if (command.mode === "structured") {
|
|
104
|
-
if (
|
|
104
|
+
if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
|
|
105
105
|
return true;
|
|
106
106
|
}
|
|
107
107
|
return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
|
|
@@ -110,21 +110,35 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* The
|
|
113
|
+
* The attributes that turn an otherwise read-only command into a device write.
|
|
114
114
|
*
|
|
115
115
|
* RouterOS routes command output to a file on the device when `file=` is
|
|
116
116
|
* supplied, so a read verb creates a file: `/ip/address/export file=address`
|
|
117
117
|
* writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
|
|
118
118
|
* from the RouterOS manual — "Configuration Export" and "Scripting Tips and
|
|
119
119
|
* Tricks", where that idiom is the documented way to create a file and needs
|
|
120
|
-
* the script `write` policy).
|
|
121
|
-
*
|
|
120
|
+
* the script `write` policy). `:serialize` spells it `file-name=` (its
|
|
121
|
+
* `/console/inspect` arguments, 7.13.5 through 7.26beta1). The verb and
|
|
122
|
+
* directive allowlists cannot see either, so they are checked independently.
|
|
122
123
|
*/
|
|
123
|
-
const
|
|
124
|
+
const FILE_OUTPUT_ATTRIBUTES: readonly string[] = ["file", "file-name"];
|
|
124
125
|
|
|
125
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* RouterOS accepts argument prefixes (`fi=`, `file-n=`), including on
|
|
128
|
+
* `:serialize` (CHR 7.26beta1). An ambiguous prefix stays write-shaped too:
|
|
129
|
+
* only the device can disambiguate it, so the local gate fails closed.
|
|
130
|
+
*/
|
|
131
|
+
function isFileOutputAttribute(name: string): boolean {
|
|
132
|
+
return (
|
|
133
|
+
name.length > 0 &&
|
|
134
|
+
FILE_OUTPUT_ATTRIBUTES.some((full) => full.startsWith(name))
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True for an output-file argument, including its abbreviated spellings. */
|
|
126
139
|
function isFileOutputToken(token: string): boolean {
|
|
127
|
-
|
|
140
|
+
const separator = token.indexOf("=");
|
|
141
|
+
return separator > 0 && isFileOutputAttribute(token.slice(0, separator));
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/**
|
|
@@ -193,27 +207,59 @@ const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
|
193
207
|
/**
|
|
194
208
|
* Script directives that are safe to run without device-write confirmation:
|
|
195
209
|
* output, control flow and pure value functions, all members of `explain`'s
|
|
196
|
-
* grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`)
|
|
197
|
-
* directive's `do={…}` or `[…]` body is its own
|
|
198
|
-
* its own.
|
|
199
|
-
* `:
|
|
200
|
-
*
|
|
210
|
+
* grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`) and
|
|
211
|
+
* present on 7.26beta1. A directive's `do={…}` or `[…]` body is its own
|
|
212
|
+
* command head and is checked on its own. That holds because a script-typed
|
|
213
|
+
* argument (`:do command=`, `:if do=`, `:retry command=`, `:time`, `:onerror
|
|
214
|
+
* in=`) runs only a `{…}` block: RouterOS refuses a quoted string or a variable
|
|
215
|
+
* holding one there as an invalid value (CHR 7.24.5 and 7.26beta1, #458), so
|
|
216
|
+
* no body can hide in text the gate blanks. The value functions (#458) take
|
|
217
|
+
* only values; `:serialize` alone can write, through `file-name=` (it creates
|
|
218
|
+
* the file on 7.26beta1), which the file-output check catches.
|
|
219
|
+
*
|
|
220
|
+
* Deliberately absent: `:execute` and `:parse` (run built code), `:global` and
|
|
221
|
+
* `:set` (outlive the script), and `:import`, `:password`, `:undo`, `:redo`
|
|
222
|
+
* (configuration changes). A directive missing here costs `--yes`; one wrongly
|
|
223
|
+
* here bypasses the gate.
|
|
201
224
|
*/
|
|
202
225
|
const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
203
226
|
":break",
|
|
204
227
|
":continue",
|
|
228
|
+
":convert",
|
|
229
|
+
":delay",
|
|
230
|
+
":deserialize",
|
|
205
231
|
":do",
|
|
206
232
|
":error",
|
|
233
|
+
":find",
|
|
207
234
|
":for",
|
|
208
235
|
":foreach",
|
|
209
236
|
":if",
|
|
237
|
+
":jobname",
|
|
210
238
|
":len",
|
|
211
239
|
":local",
|
|
240
|
+
":nothing",
|
|
212
241
|
":onerror",
|
|
213
242
|
":pick",
|
|
214
243
|
":put",
|
|
244
|
+
":range",
|
|
245
|
+
":retry",
|
|
215
246
|
":return",
|
|
247
|
+
":rndnum",
|
|
248
|
+
":rndstr",
|
|
249
|
+
":serialize",
|
|
250
|
+
":time",
|
|
251
|
+
":timestamp",
|
|
252
|
+
":toarray",
|
|
253
|
+
":tobool",
|
|
254
|
+
":tocrlf",
|
|
255
|
+
":toid",
|
|
256
|
+
":toip",
|
|
257
|
+
":toip6",
|
|
258
|
+
":tolf",
|
|
259
|
+
":tonsec",
|
|
260
|
+
":tonum",
|
|
216
261
|
":tostr",
|
|
262
|
+
":totime",
|
|
217
263
|
":typeof",
|
|
218
264
|
":while",
|
|
219
265
|
]);
|
|
@@ -249,7 +295,11 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
|
|
|
249
295
|
// Every command the statement runs must be proven read-only, not just the
|
|
250
296
|
// first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
|
|
251
297
|
// reads with `find` and writes with an unlearned mutator.
|
|
252
|
-
if (
|
|
298
|
+
if (
|
|
299
|
+
!commandHeads(statement).every(
|
|
300
|
+
(head) => !head.spacedFileOutput && isReadOnlyCommand(head.words),
|
|
301
|
+
)
|
|
302
|
+
) {
|
|
253
303
|
return true;
|
|
254
304
|
}
|
|
255
305
|
}
|
|
@@ -264,12 +314,23 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
|
|
|
264
314
|
* expressions, not commands, so they do not start one. Each head reads only up
|
|
265
315
|
* to the next opener, which begins its own head.
|
|
266
316
|
*/
|
|
267
|
-
function commandHeads(
|
|
268
|
-
|
|
317
|
+
function commandHeads(
|
|
318
|
+
statement: string,
|
|
319
|
+
): { words: string[]; spacedFileOutput: boolean }[] {
|
|
320
|
+
const heads: { words: string[]; spacedFileOutput: boolean }[] = [];
|
|
269
321
|
const starts = [0];
|
|
322
|
+
// Match group ends once. Each command can then skip its nested bodies
|
|
323
|
+
// without walking them again; deeply nested reads stay linear in input size.
|
|
324
|
+
const closes = new Map<number, number>();
|
|
325
|
+
const open: number[] = [];
|
|
270
326
|
for (let index = 0; index < statement.length; index += 1) {
|
|
271
|
-
const char = statement[index];
|
|
327
|
+
const char = statement[index] ?? "";
|
|
272
328
|
if (char === "[" || char === "{") starts.push(index + 1);
|
|
329
|
+
if ("[{(".includes(char)) open.push(index);
|
|
330
|
+
else if ("]})".includes(char)) {
|
|
331
|
+
const start = open.pop();
|
|
332
|
+
if (start !== undefined) closes.set(start, index);
|
|
333
|
+
}
|
|
273
334
|
}
|
|
274
335
|
for (const start of starts) {
|
|
275
336
|
const nextOpener = statement.slice(start).search(/[[{(]/);
|
|
@@ -282,11 +343,58 @@ function commandHeads(statement: string): string[][] {
|
|
|
282
343
|
if (head === undefined) continue;
|
|
283
344
|
const words = commandWords(head, tokens.slice(1));
|
|
284
345
|
// A lone closer (`}` ending a multi-line block) runs nothing.
|
|
285
|
-
if (words.length > 0)
|
|
346
|
+
if (words.length > 0)
|
|
347
|
+
heads.push({
|
|
348
|
+
words,
|
|
349
|
+
spacedFileOutput: hasSpacedFileOutput(statement, start, words, closes),
|
|
350
|
+
});
|
|
286
351
|
}
|
|
287
352
|
return heads;
|
|
288
353
|
}
|
|
289
354
|
|
|
355
|
+
/**
|
|
356
|
+
* RouterOS also accepts `file value` / `file-name value` without `=`. These
|
|
357
|
+
* belong to print/export/serialize, not a loop variable or a pure function's
|
|
358
|
+
* value. Read the command's own tokens, skipping nested expressions and
|
|
359
|
+
* blocks: `:serialize value=[:tostr file] file-n "out"` has one file argument
|
|
360
|
+
* after its nested read. Quoted text is already masked by the statement split.
|
|
361
|
+
* Grounded on CHR 7.26beta1, with export also checked on long-term.
|
|
362
|
+
*/
|
|
363
|
+
function hasSpacedFileOutput(
|
|
364
|
+
statement: string,
|
|
365
|
+
start: number,
|
|
366
|
+
words: readonly string[],
|
|
367
|
+
closes: ReadonlyMap<number, number>,
|
|
368
|
+
): boolean {
|
|
369
|
+
let commandWords: number;
|
|
370
|
+
if (words[0] === ":serialize") {
|
|
371
|
+
commandWords = 1;
|
|
372
|
+
} else {
|
|
373
|
+
const depth = menuDepth(words);
|
|
374
|
+
if (words[depth] !== "print" && words[depth] !== "export") return false;
|
|
375
|
+
commandWords = depth + 1;
|
|
376
|
+
}
|
|
377
|
+
let own = "";
|
|
378
|
+
for (let index = start; index < statement.length; index += 1) {
|
|
379
|
+
const char = statement[index] ?? "";
|
|
380
|
+
if ("[{(".includes(char)) {
|
|
381
|
+
own += " ";
|
|
382
|
+
index = closes.get(index) ?? statement.length;
|
|
383
|
+
} else if ("]})".includes(char)) {
|
|
384
|
+
break;
|
|
385
|
+
} else own += char;
|
|
386
|
+
}
|
|
387
|
+
let consumed = 0;
|
|
388
|
+
for (const token of tokenizeRouterOsCli(own)) {
|
|
389
|
+
if (consumed < commandWords) {
|
|
390
|
+
consumed += token.split("/").filter(Boolean).length;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!token.includes("=") && isFileOutputAttribute(token)) return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
290
398
|
/**
|
|
291
399
|
* The path segments and verb of one command, from its head token and the bare
|
|
292
400
|
* words after it. The space-separated spelling puts menu segments and the verb
|
|
@@ -299,6 +407,19 @@ function commandWords(head: string, following: readonly string[]): string[] {
|
|
|
299
407
|
const words: string[] = [];
|
|
300
408
|
const headWord = trimGroupingPunctuation(head);
|
|
301
409
|
if (headWord.startsWith(":")) return [headWord];
|
|
410
|
+
// `/serialize …` is the root command `:serialize`: at statement start the
|
|
411
|
+
// sigil is optional and `/` binds the root vocabulary (CHR 7.23.2, see
|
|
412
|
+
// `src/explain/symbols.ts`). Only the absolute one-segment form is read
|
|
413
|
+
// this way. A bare `delay` may be a relative menu command after earlier
|
|
414
|
+
// navigation, so it keeps the menu-verb rule and fails closed.
|
|
415
|
+
const root = /^\/([A-Za-z0-9-]+)$/.exec(headWord)?.[1]?.toLowerCase();
|
|
416
|
+
if (
|
|
417
|
+
root !== undefined &&
|
|
418
|
+
READ_ONLY_SCRIPT_DIRECTIVES.has(`:${root}`) &&
|
|
419
|
+
!isKnownMenuPath([root])
|
|
420
|
+
) {
|
|
421
|
+
return [`:${root}`];
|
|
422
|
+
}
|
|
302
423
|
words.push(...headWord.split("/").filter(Boolean));
|
|
303
424
|
if (headWord !== head) return words;
|
|
304
425
|
for (const token of following) {
|
|
@@ -325,12 +446,16 @@ function commandWords(head: string, following: readonly string[]): string[] {
|
|
|
325
446
|
function isReadOnlyCommand(words: readonly string[]): boolean {
|
|
326
447
|
const head = words[0] ?? "";
|
|
327
448
|
if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
|
|
449
|
+
const verb = words[menuDepth(words)];
|
|
450
|
+
return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function menuDepth(words: readonly string[]): number {
|
|
328
454
|
let depth = 0;
|
|
329
455
|
while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
|
|
330
456
|
depth += 1;
|
|
331
457
|
}
|
|
332
|
-
|
|
333
|
-
return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
|
|
458
|
+
return depth;
|
|
334
459
|
}
|
|
335
460
|
|
|
336
461
|
function trimGroupingPunctuation(value: string): string {
|