@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.24
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 +106 -0
- package/GLOSSARY.txt +14 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +452 -61
- package/commands/explain/examples.md +191 -41
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +34 -21
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +2 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +136 -1
- package/src/explain/symbols.ts +146 -6
- package/src/explain/values.ts +55 -4
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +395 -43
- package/src/index.ts +6 -0
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
package/src/core/envelope.ts
CHANGED
|
@@ -185,10 +185,17 @@ export interface EnvelopeValidationMeta {
|
|
|
185
185
|
* `stages[]` then says which stage was not reached and why (GH#435).
|
|
186
186
|
*/
|
|
187
187
|
result?: "passed" | "failed" | "skipped";
|
|
188
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* The syntax verdict (`:parse` or the offline analyzer). Present only when a
|
|
190
|
+
* stage gave one; absent means no stage was asked (#535).
|
|
191
|
+
*/
|
|
189
192
|
syntax?: boolean;
|
|
190
|
-
/**
|
|
191
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The name-level verdict (`/console/inspect` or a console `:parse` `bad
|
|
195
|
+
* parameter`). Present only when a stage gave one; absent means no stage was
|
|
196
|
+
* asked, including for script input, which has no names to check (#535).
|
|
197
|
+
*/
|
|
198
|
+
semantic?: boolean;
|
|
192
199
|
availableAttributes?: readonly string[];
|
|
193
200
|
/**
|
|
194
201
|
* Per-stage detail for the gate described in
|
|
@@ -431,6 +431,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
431
431
|
code: "routeros/mac-telnet-unsupported-auth",
|
|
432
432
|
summary: "The device requested an unsupported MAC-Telnet auth mode.",
|
|
433
433
|
},
|
|
434
|
+
{
|
|
435
|
+
code: "routeros/permission-denied",
|
|
436
|
+
summary:
|
|
437
|
+
"RouterOS refused the command because the user's group lacks a policy it needs.",
|
|
438
|
+
},
|
|
434
439
|
{
|
|
435
440
|
code: "routeros/protocol-not-implemented",
|
|
436
441
|
summary: "The requested protocol is planned but not implemented yet.",
|
|
@@ -563,6 +568,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
563
568
|
code: "transport/dns",
|
|
564
569
|
summary: "The device host name could not be resolved.",
|
|
565
570
|
},
|
|
571
|
+
{
|
|
572
|
+
code: "transport/edge-auth-failed",
|
|
573
|
+
summary:
|
|
574
|
+
"An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the request before RouterOS answered it.",
|
|
575
|
+
},
|
|
566
576
|
{
|
|
567
577
|
code: "transport/host-key-mismatch",
|
|
568
578
|
summary: "The device's SSH host key did not match the known key.",
|
|
@@ -652,6 +662,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
652
662
|
summary:
|
|
653
663
|
"The command or mode cannot fan out across multiple targets (e.g. terminal, --listen/--stream).",
|
|
654
664
|
},
|
|
665
|
+
{
|
|
666
|
+
code: "usage/invalid-cursor",
|
|
667
|
+
summary:
|
|
668
|
+
"An explain --cursor is not a byte offset inside the input being analyzed.",
|
|
669
|
+
},
|
|
655
670
|
{
|
|
656
671
|
code: "usage/invalid-method",
|
|
657
672
|
summary: "The -X/--method value is not a supported HTTP method.",
|
|
@@ -104,11 +104,64 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
|
104
104
|
if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
|
|
105
105
|
return true;
|
|
106
106
|
}
|
|
107
|
+
if (isBareMenuNavigation(command)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
107
110
|
return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
|
|
108
111
|
}
|
|
109
112
|
return scriptContainsWriteShapedCommand(command.input);
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A truly bare known-menu path (`/system/identity`, no attributes or queries)
|
|
117
|
+
* is console navigation — a no-op read, not a command — so it needs no write
|
|
118
|
+
* confirmation. The APIs refuse it (`no such command`) and the consoles
|
|
119
|
+
* navigate silently; neither mutates.
|
|
120
|
+
*
|
|
121
|
+
* This is narrower than the script branch on purpose. There, `/ip firewall
|
|
122
|
+
* filter` stays write-shaped (#387): a later line may carry a relative verb,
|
|
123
|
+
* so navigation fails closed. Here structured mode proves the input is a
|
|
124
|
+
* single path token (any second word without `=` — including a second line —
|
|
125
|
+
* falls back to script), so no follow-up is possible. The carve-out still
|
|
126
|
+
* fails closed three ways: anything the menu table does not carry stays gated,
|
|
127
|
+
* a verb in the known-mutator list stays gated even if the table ever
|
|
128
|
+
* mis-tags its path, and any attribute or query (including `;`-smuggled text,
|
|
129
|
+
* which lands in the verb or an attribute, never in a clean menu reading)
|
|
130
|
+
* keeps the gate shut. The gate is shared, so bare menus also stop tripping
|
|
131
|
+
* `cdb/write-not-permitted` on `mcp=ro` targets (and stop needing
|
|
132
|
+
* `confirm:true` on `mcp=rw` ones), and `explain`'s `writeShaped` flips to
|
|
133
|
+
* false for these inputs — harmless in all three, since the input is a
|
|
134
|
+
* no-op read either way.
|
|
135
|
+
*/
|
|
136
|
+
function isBareMenuNavigation(command: CanonicalExecuteCommand): boolean {
|
|
137
|
+
if (
|
|
138
|
+
Object.keys(command.attributes).length > 0 ||
|
|
139
|
+
command.queries.length > 0
|
|
140
|
+
) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (KNOWN_WRITE_EXECUTE_VERBS.has(command.verb.toLowerCase())) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const segments = `${command.path}/${command.verb}`.split("/").filter(Boolean);
|
|
147
|
+
if (segments.length < 2) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return isKnownMenuPath(segments);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether `execute` was asked to run a bare menu path (see
|
|
155
|
+
* {@link isBareMenuNavigation}). The envelope carries a steering tip for it:
|
|
156
|
+
* navigation runs — the gate no longer stops it — but it answers nothing, so
|
|
157
|
+
* the tip names the read that does.
|
|
158
|
+
*/
|
|
159
|
+
export function isBareMenuPathCommand(
|
|
160
|
+
command: CanonicalExecuteCommand,
|
|
161
|
+
): boolean {
|
|
162
|
+
return command.mode === "structured" && isBareMenuNavigation(command);
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
/**
|
|
113
166
|
* The attributes that turn an otherwise read-only command into a device write.
|
|
114
167
|
*
|
package/src/core/fanout.ts
CHANGED
|
@@ -94,6 +94,7 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
|
|
|
94
94
|
"transport/auth-failed",
|
|
95
95
|
"transport/connection-refused",
|
|
96
96
|
"transport/dns",
|
|
97
|
+
"transport/edge-auth-failed",
|
|
97
98
|
"transport/host-key-mismatch",
|
|
98
99
|
"transport/insecure-trust",
|
|
99
100
|
"transport/local-tool-missing",
|
|
@@ -106,15 +107,24 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
|
|
|
106
107
|
* #448: a write whose request was already sent when a transport failure ended
|
|
107
108
|
* it may have run on the router. It is never retried, and its error envelope
|
|
108
109
|
* says the outcome is unknown rather than implying nothing happened.
|
|
110
|
+
*
|
|
111
|
+
* #549: the same warning covers a dispatched multi-statement write refused
|
|
112
|
+
* with `routeros/permission-denied`, which can be partly applied — statements
|
|
113
|
+
* before the refused one already ran. A single refused statement did not run
|
|
114
|
+
* (#541), so it stays silent; the caller passes whether the dispatch carried
|
|
115
|
+
* more than one statement.
|
|
109
116
|
*/
|
|
110
117
|
export function sentWriteWarnings(
|
|
111
118
|
write: boolean,
|
|
112
119
|
error: unknown,
|
|
113
120
|
trace: ValidationTrace | undefined,
|
|
121
|
+
multiStatement = false,
|
|
114
122
|
): Warning[] {
|
|
115
123
|
if (!write || !trace?.dispatched) return [];
|
|
116
|
-
if (
|
|
117
|
-
|
|
124
|
+
if (!(error instanceof CentrsError)) return [];
|
|
125
|
+
if (error.code === "routeros/permission-denied") {
|
|
126
|
+
if (!multiStatement) return [];
|
|
127
|
+
} else if (
|
|
118
128
|
!error.code.startsWith("transport/") ||
|
|
119
129
|
NOT_SENT_TRANSPORT_CODES.includes(error.code)
|
|
120
130
|
) {
|
package/src/core/inspect.ts
CHANGED
|
@@ -39,12 +39,11 @@ export interface InspectBackend {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
|
|
42
|
-
* and `completion` are wired
|
|
42
|
+
* and `completion` are wired here (the two the {@link InspectBackend} seam
|
|
43
43
|
* exposes), plus `input` for completion over CLI text
|
|
44
|
-
* ({@link inspectWhereBooleans})
|
|
45
|
-
* `syntax`
|
|
46
|
-
*
|
|
47
|
-
* seam when adding them.
|
|
44
|
+
* ({@link inspectWhereBooleans}). The protocol adapter also supports
|
|
45
|
+
* `highlight` and `syntax` for live explain, which sends `syntax` for argument
|
|
46
|
+
* paths only (`src/explain-live-facets.ts`).
|
|
48
47
|
*/
|
|
49
48
|
export type InspectRequestKind =
|
|
50
49
|
| "child"
|
|
@@ -107,6 +107,31 @@ function cleanToken(token: string): string {
|
|
|
107
107
|
* rules here; keep the most specific patterns above broader ones.
|
|
108
108
|
*/
|
|
109
109
|
export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
110
|
+
{
|
|
111
|
+
code: "routeros/permission-denied",
|
|
112
|
+
description:
|
|
113
|
+
"RouterOS refused the command because the user's group lacks a policy it needs.",
|
|
114
|
+
// Grounded on CHR 7.25. REST answers HTTP 500 with this as `detail`:
|
|
115
|
+
// `not enough permissions (9)` for a write without `write`, and
|
|
116
|
+
// `std failure: not allowed (9)` for any request (even a GET) when the
|
|
117
|
+
// group has `rest-api` but not `api` (fixed in 7.26beta3). The native API traps with the
|
|
118
|
+
// first string; `/execute as-string` returns it as text with a
|
|
119
|
+
// `(<path>; line N)` suffix. In a multi-statement script the refusal is
|
|
120
|
+
// the last line, after earlier statements' stdout (#549: `:put
|
|
121
|
+
// "p5-first"; /system/identity/set …` as a read-only user returns HTTP
|
|
122
|
+
// 200 with ret `p5-first\r\nnot enough permissions (9)
|
|
123
|
+
// (/system/identity/set; line 1)`, identical on native-api), so the
|
|
124
|
+
// trailed form matches too — with the location suffix required, so a
|
|
125
|
+
// script that merely prints the bare phrase stays ordinary output.
|
|
126
|
+
test: /not enough permissions|not allowed \(9\)/i,
|
|
127
|
+
resultTest:
|
|
128
|
+
/^(?:not enough permissions \(9\)(?: \(\/[^\s();]+; line \d+\))?|[\s\S]*\r?\nnot enough permissions \(9\) \(\/[^\s();]+; line \d+\))$/i,
|
|
129
|
+
build: (_match, raw) => ({
|
|
130
|
+
summary: `RouterOS refused the command for lack of permission: ${raw.trim()}`,
|
|
131
|
+
remediation:
|
|
132
|
+
"Grant the missing policy to the RouterOS user's group (`/user/group`): `read` for reads, `write` for changes. If every REST request fails with `not allowed (9)`, the group has `rest-api` without `api`; before RouterOS 7.26beta3, REST needs both. Retrying will not help.",
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
110
135
|
{
|
|
111
136
|
code: "routeros/unknown-path",
|
|
112
137
|
description:
|
package/src/execute-cmr.ts
CHANGED
|
@@ -1002,8 +1002,7 @@ function devicePassed(
|
|
|
1002
1002
|
source: CMR_VALIDATION_SOURCE,
|
|
1003
1003
|
result: "passed",
|
|
1004
1004
|
syntax: true,
|
|
1005
|
-
semantic:
|
|
1006
|
-
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
1005
|
+
...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
|
|
1007
1006
|
stages: passedStages(
|
|
1008
1007
|
offline ?? { verdict: "pass", warnings: [] },
|
|
1009
1008
|
CMR_VALIDATION_SOURCE,
|
|
@@ -1023,12 +1022,7 @@ function deviceRejected(
|
|
|
1023
1022
|
enabled: true,
|
|
1024
1023
|
source: CMR_VALIDATION_SOURCE,
|
|
1025
1024
|
result: "failed",
|
|
1026
|
-
...(nameRejected
|
|
1027
|
-
? { syntax: true, semantic: false }
|
|
1028
|
-
: {
|
|
1029
|
-
syntax: false,
|
|
1030
|
-
...(structured ? {} : { semantic: "not-applicable" as const }),
|
|
1031
|
-
}),
|
|
1025
|
+
...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
|
|
1032
1026
|
stages: [
|
|
1033
1027
|
offlineStagePassed(offline?.verdict),
|
|
1034
1028
|
{ stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
|
package/src/execute.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { buildTip, notReachedReason } from "./core/envelope.ts";
|
|
|
27
27
|
import {
|
|
28
28
|
type CanonicalExecuteCommand,
|
|
29
29
|
canonicalizeExecuteCommand,
|
|
30
|
+
isBareMenuPathCommand,
|
|
30
31
|
isWriteShaped,
|
|
31
32
|
} from "./core/execute-command.ts";
|
|
32
33
|
import { sentWriteWarnings } from "./core/fanout.ts";
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
SYNTAX_REJECTED_REMEDIATION,
|
|
41
42
|
serializeCentrsError,
|
|
42
43
|
} from "./errors.ts";
|
|
44
|
+
import { countExecutableStatements } from "./explain/segment.ts";
|
|
43
45
|
import {
|
|
44
46
|
assertOfflineSyntax,
|
|
45
47
|
isOfflineGateRejection,
|
|
@@ -162,6 +164,7 @@ export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
|
|
|
162
164
|
export {
|
|
163
165
|
type CanonicalExecuteCommand,
|
|
164
166
|
canonicalizeExecuteCommand,
|
|
167
|
+
isBareMenuPathCommand,
|
|
165
168
|
isWriteShaped,
|
|
166
169
|
};
|
|
167
170
|
|
|
@@ -608,6 +611,22 @@ export function buildExecuteErrorEnvelope(
|
|
|
608
611
|
};
|
|
609
612
|
}
|
|
610
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Whether the dispatch carried more than one statement (#549). Mirrors
|
|
616
|
+
* `runCommand`: the consoles run the raw CLI text whatever the canonical mode,
|
|
617
|
+
* while REST/native run structured input as one path-POST/talk sentence — a
|
|
618
|
+
* `;` inside a structured value never separates there, so the refusal means
|
|
619
|
+
* nothing ran. Executed control scopes count too: `:if … do={ A; B }` applies
|
|
620
|
+
* A before a refused B, while stored bodies never run at dispatch.
|
|
621
|
+
*/
|
|
622
|
+
function isMultiStatementDispatch(resolved: ResolvedExecuteRequest): boolean {
|
|
623
|
+
const runsRawText =
|
|
624
|
+
resolved.via.value === "mac-telnet" ||
|
|
625
|
+
resolved.via.value === "ssh" ||
|
|
626
|
+
resolved.canonical.mode === "script";
|
|
627
|
+
return runsRawText && countExecutableStatements(resolved.command) > 1;
|
|
628
|
+
}
|
|
629
|
+
|
|
611
630
|
export function buildExecuteErrorEnvelopeFromResolved(
|
|
612
631
|
resolved: ResolvedExecuteRequest,
|
|
613
632
|
error: unknown,
|
|
@@ -633,6 +652,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
633
652
|
isWriteShaped(resolved.canonical),
|
|
634
653
|
centrsError,
|
|
635
654
|
trace,
|
|
655
|
+
isMultiStatementDispatch(resolved),
|
|
636
656
|
),
|
|
637
657
|
],
|
|
638
658
|
tips: resolvedExecuteTips(resolved),
|
|
@@ -664,20 +684,15 @@ function failedValidationMeta(
|
|
|
664
684
|
}
|
|
665
685
|
const deviceSource = deviceStageSource(resolved);
|
|
666
686
|
const structured = resolved.canonical.mode === "structured";
|
|
667
|
-
// A flat summary is present only when
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// never asked, so `semantic` is left out rather than reported `false`.
|
|
671
|
-
const semanticNotReached = structured
|
|
672
|
-
? {}
|
|
673
|
-
: { semantic: "not-applicable" as const };
|
|
687
|
+
// A flat summary is present only when a stage gave that verdict (#535). A
|
|
688
|
+
// syntax rejection means the name-level check was never asked, so
|
|
689
|
+
// `semantic` is left out rather than reported `false`.
|
|
674
690
|
if (isOfflineGateRejection(error)) {
|
|
675
691
|
return {
|
|
676
692
|
enabled: true,
|
|
677
693
|
source: OFFLINE_GATE_SOURCE,
|
|
678
694
|
result: "failed",
|
|
679
695
|
syntax: false,
|
|
680
|
-
...semanticNotReached,
|
|
681
696
|
stages: [
|
|
682
697
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
683
698
|
{
|
|
@@ -705,9 +720,7 @@ function failedValidationMeta(
|
|
|
705
720
|
enabled: true,
|
|
706
721
|
source: deviceSource,
|
|
707
722
|
result: "failed",
|
|
708
|
-
...(nameRejected
|
|
709
|
-
? { syntax: true, semantic: false }
|
|
710
|
-
: { syntax: false, ...semanticNotReached }),
|
|
723
|
+
...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
|
|
711
724
|
stages: [
|
|
712
725
|
offlineStagePassed(trace?.offlineVerdict),
|
|
713
726
|
{ stage: "device", source: deviceSource, result: "failed" },
|
|
@@ -781,15 +794,14 @@ export function offlineStagePassed(
|
|
|
781
794
|
* `validate=false`. Both stages are listed as `skipped` rather than omitted:
|
|
782
795
|
* the constitution's rule is that a gated surface always reports each stage, so
|
|
783
796
|
* a consumer reading `stages` never has to special-case the disabled shape.
|
|
784
|
-
* `reason` is left off — `enabled: false` already says why.
|
|
797
|
+
* `reason` is left off — `enabled: false` already says why. No stage answered,
|
|
798
|
+
* so there is no `syntax` / `semantic` summary (#535).
|
|
785
799
|
*/
|
|
786
800
|
export function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
787
801
|
return {
|
|
788
802
|
enabled: false,
|
|
789
803
|
source: "disabled",
|
|
790
804
|
result: "skipped",
|
|
791
|
-
syntax: false,
|
|
792
|
-
semantic: false,
|
|
793
805
|
stages: [
|
|
794
806
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
795
807
|
{ stage: "device", source: "device preflight", result: "skipped" },
|
|
@@ -862,7 +874,6 @@ async function validateExecuteCommand(
|
|
|
862
874
|
source: ":put [:parse]",
|
|
863
875
|
result: "passed",
|
|
864
876
|
syntax: true,
|
|
865
|
-
semantic: "not-applicable",
|
|
866
877
|
stages: passedStages(offline, ":put [:parse]"),
|
|
867
878
|
};
|
|
868
879
|
}
|
|
@@ -929,8 +940,7 @@ async function validateConsoleParseCommand(
|
|
|
929
940
|
source: `:put [:parse ...] over ${resolved.via.value}`,
|
|
930
941
|
result: "passed",
|
|
931
942
|
syntax: true,
|
|
932
|
-
semantic:
|
|
933
|
-
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
943
|
+
...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
|
|
934
944
|
stages: passedStages(
|
|
935
945
|
offline,
|
|
936
946
|
`:put [:parse ...] over ${resolved.via.value}`,
|
|
@@ -1452,20 +1462,38 @@ function executeRequestSummary(
|
|
|
1452
1462
|
* downstream auth error.
|
|
1453
1463
|
*/
|
|
1454
1464
|
export function resolvedExecuteTips(resolved: ResolvedExecuteRequest): Tip[] {
|
|
1465
|
+
const tips: Tip[] = [];
|
|
1455
1466
|
if (
|
|
1456
1467
|
resolved.via.value === "mac-telnet" &&
|
|
1457
1468
|
resolved.auth.username === undefined &&
|
|
1458
1469
|
!resolved.auth.passwordProvided
|
|
1459
1470
|
) {
|
|
1460
|
-
|
|
1471
|
+
tips.push(
|
|
1461
1472
|
buildTip(
|
|
1462
1473
|
"tip/mac-telnet-no-credentials",
|
|
1463
1474
|
"No username or password was resolved for this mac-telnet target.",
|
|
1464
1475
|
"mac-telnet logs in with RouterOS credentials over MTWEI (MD5 is refused): pass --username/--password, set CENTRS_USERNAME/CENTRS_PASSWORD, or add a CDB record for this device.",
|
|
1465
1476
|
),
|
|
1466
|
-
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
// A bare menu path runs — the write gate no longer stops it — but it
|
|
1480
|
+
// answers nothing: consoles navigate silently and the APIs refuse it with
|
|
1481
|
+
// `no such command`. Name the read that does answer. `canonical` is
|
|
1482
|
+
// always resolved in production; the guard keeps hermetic stubs total.
|
|
1483
|
+
if (
|
|
1484
|
+
resolved.canonical !== undefined &&
|
|
1485
|
+
isBareMenuPathCommand(resolved.canonical)
|
|
1486
|
+
) {
|
|
1487
|
+
const menu = `${resolved.canonical.path}/${resolved.canonical.verb}`;
|
|
1488
|
+
tips.push(
|
|
1489
|
+
buildTip(
|
|
1490
|
+
"tip/execute-bare-menu-path",
|
|
1491
|
+
`"${menu}" is a menu path, not a command: consoles navigate to it (no output) and the APIs refuse it with "no such command".`,
|
|
1492
|
+
`Append a verb such as "${menu}/print", or read rows with "centrs retrieve <router> ${menu}".`,
|
|
1493
|
+
),
|
|
1494
|
+
);
|
|
1467
1495
|
}
|
|
1468
|
-
return
|
|
1496
|
+
return tips;
|
|
1469
1497
|
}
|
|
1470
1498
|
|
|
1471
1499
|
function dataFromResult(result: ProtocolExecuteResult): unknown {
|
package/src/explain/args.ts
CHANGED
|
@@ -78,7 +78,12 @@
|
|
|
78
78
|
|
|
79
79
|
import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
|
|
80
80
|
import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
|
|
81
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
braceBodyIsScriptLike,
|
|
83
|
+
braceStartsStatements,
|
|
84
|
+
isScriptBodyName,
|
|
85
|
+
scopeNameFromMasked,
|
|
86
|
+
} from "./scope-brace.ts";
|
|
82
87
|
import {
|
|
83
88
|
type MaskedRange,
|
|
84
89
|
maskComments,
|
|
@@ -353,6 +358,34 @@ function braceValueOwner(
|
|
|
353
358
|
return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
|
|
354
359
|
}
|
|
355
360
|
|
|
361
|
+
/**
|
|
362
|
+
* The `name` of a `name= {…}` brace: the token before the gap ends in a glued
|
|
363
|
+
* `name=` run (#554).
|
|
364
|
+
*
|
|
365
|
+
* {@link braceValueOwner} only sees the `=` glued to the brace (`down={`). A
|
|
366
|
+
* space after the `=` starts a new token, so the brace reads as positional
|
|
367
|
+
* there — but on CHR 7.25 `down= { :beep }` lowers to `down-script=` exactly
|
|
368
|
+
* like the glued spelling, while `comment= {1;2}` is a `syntax error` at the
|
|
369
|
+
* `{`. Only spaces and tabs span the gap: a newline there is a statement
|
|
370
|
+
* boundary, not a gap, and the name run must start its token, so `=down= {`
|
|
371
|
+
* and `down = {` own nothing.
|
|
372
|
+
*/
|
|
373
|
+
function spacedBraceOwner(text: string, at: number): string | undefined {
|
|
374
|
+
let j = at - 1;
|
|
375
|
+
let gap = 0;
|
|
376
|
+
while (text[j] === " " || text[j] === "\t") {
|
|
377
|
+
j--;
|
|
378
|
+
gap++;
|
|
379
|
+
}
|
|
380
|
+
if (gap === 0 || text[j] !== "=") return undefined;
|
|
381
|
+
const nameEnd = j;
|
|
382
|
+
j--;
|
|
383
|
+
while (j >= 0 && /[A-Za-z0-9.-]/.test(text[j] as string)) j--;
|
|
384
|
+
if (j >= 0 && !/[\s([]/.test(text[j] as string)) return undefined;
|
|
385
|
+
const name = text.slice(j + 1, nameEnd);
|
|
386
|
+
return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
356
389
|
/**
|
|
357
390
|
* Whether a `{` at `at` may open an array literal in this statement.
|
|
358
391
|
*
|
|
@@ -1715,6 +1748,22 @@ export function comparesNamedValue(
|
|
|
1715
1748
|
return /^[A-Za-z.]/.test(name) && /[\s(]/.test(text[j] as string);
|
|
1716
1749
|
}
|
|
1717
1750
|
|
|
1751
|
+
/** One top-level `{` in command arguments that is not a proven body. */
|
|
1752
|
+
export interface CommandBraceFinding {
|
|
1753
|
+
/** Offset of the `{` in the text passed to the lexer. */
|
|
1754
|
+
at: number;
|
|
1755
|
+
/**
|
|
1756
|
+
* True when `name={` with a script-like body on a non-root command outside
|
|
1757
|
+
* `where`, where `name` is not a proven script-body slot (#525). The brace
|
|
1758
|
+
* may abbreviate one (`down=` for `down-script=`), but telling it from
|
|
1759
|
+
* `comment=` needs the command's argument list, which is schema — so the
|
|
1760
|
+
* caller warns instead of rejecting, and the device decides.
|
|
1761
|
+
*/
|
|
1762
|
+
ambiguous: boolean;
|
|
1763
|
+
/** The `name` of `name=`, lower-cased, when `ambiguous` is true. */
|
|
1764
|
+
name?: string;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1718
1767
|
/**
|
|
1719
1768
|
* Locate top-level `{...}` values that RouterOS rejects in command arguments.
|
|
1720
1769
|
*
|
|
@@ -1724,15 +1773,21 @@ export function comparesNamedValue(
|
|
|
1724
1773
|
* excluded: their brace is a script body, not an array. The caller applies
|
|
1725
1774
|
* this only to non-root commands; root scripting directives use the
|
|
1726
1775
|
* `(verb, slot)` table instead.
|
|
1776
|
+
*
|
|
1777
|
+
* A `name={` with a script-like body that is not a proven slot is returned
|
|
1778
|
+
* with `ambiguous: true` rather than as an error (#525), and so is the spaced
|
|
1779
|
+
* `name= {…}` spelling (`down= { :beep }` lowers to `down-script=` on CHR
|
|
1780
|
+
* 7.25). Array-content braces (`comment={1;2}`), positional braces, `where`
|
|
1781
|
+
* comparisons and root slots keep their rejection.
|
|
1727
1782
|
*/
|
|
1728
1783
|
export function invalidCommandBraceOffsets(
|
|
1729
1784
|
text: string,
|
|
1730
1785
|
from: number,
|
|
1731
1786
|
directiveVerb?: string,
|
|
1732
1787
|
range?: MaskedRange,
|
|
1733
|
-
):
|
|
1788
|
+
): CommandBraceFinding[] {
|
|
1734
1789
|
const structural = maskOf(range) ?? maskComments(text);
|
|
1735
|
-
const offsets:
|
|
1790
|
+
const offsets: CommandBraceFinding[] = [];
|
|
1736
1791
|
const stack: string[] = [];
|
|
1737
1792
|
let i = Math.max(0, from);
|
|
1738
1793
|
let tokenStart: number | null = null;
|
|
@@ -1783,7 +1838,54 @@ export function invalidCommandBraceOffsets(
|
|
|
1783
1838
|
tokenStart === null
|
|
1784
1839
|
? undefined
|
|
1785
1840
|
: braceValueOwner(structural, tokenStart, i);
|
|
1786
|
-
|
|
1841
|
+
// A space after the `=` owns the brace the same way the glued `=`
|
|
1842
|
+
// does (#554), so the slot attributes the spaced owner before
|
|
1843
|
+
// falling back to positional: `:put x= { :beep }` reads slot `x`,
|
|
1844
|
+
// not `#0` (which `put` accepts, swallowing the error).
|
|
1845
|
+
const spacedOwner =
|
|
1846
|
+
typeof owner === "string" ? undefined : spacedBraceOwner(structural, i);
|
|
1847
|
+
const slot = owner === null ? (spacedOwner ?? `#${positionals}`) : owner;
|
|
1848
|
+
// A `where` named comparison reads the brace with the property's own
|
|
1849
|
+
// parser and rejects it (`expected <name> value`), so the #525 body
|
|
1850
|
+
// guess must not shield it below — `gateway={ :beep }` included.
|
|
1851
|
+
const inWhereComparison =
|
|
1852
|
+
directiveVerb === undefined &&
|
|
1853
|
+
whereAt !== null &&
|
|
1854
|
+
comparesNamedValue(structural, i, whereAt);
|
|
1855
|
+
// The ambiguity check comes first: `braceStartsStatements` reads an
|
|
1856
|
+
// unproven `name={` with a script-like body as a body too (#525), so
|
|
1857
|
+
// reaching that skip would swallow the warning that marks the guess.
|
|
1858
|
+
// Control scopes (`do={` on a monitor, `:retry … command={`) are not
|
|
1859
|
+
// guesses — the scope table already proves them — so they stay out.
|
|
1860
|
+
// A space after the `=` owns the brace the same way the glued `=`
|
|
1861
|
+
// does (`down= { :beep }` lowers to `down-script=` on CHR 7.25), so
|
|
1862
|
+
// the spaced owner warns under the same gates.
|
|
1863
|
+
const scopeName =
|
|
1864
|
+
directiveVerb === undefined && whereAt === null
|
|
1865
|
+
? range === undefined
|
|
1866
|
+
? scopeNameFromMasked(structural, i)
|
|
1867
|
+
: scopeNameFromMasked(range.masked, range.start + i, range.start)
|
|
1868
|
+
: null;
|
|
1869
|
+
const ambiguousOwner = typeof owner === "string" ? owner : spacedOwner;
|
|
1870
|
+
if (
|
|
1871
|
+
directiveVerb === undefined &&
|
|
1872
|
+
whereAt === null &&
|
|
1873
|
+
scopeName === null &&
|
|
1874
|
+
typeof ambiguousOwner === "string" &&
|
|
1875
|
+
!isScriptBodyName(ambiguousOwner) &&
|
|
1876
|
+
braceBodyIsScriptLike(structural, i, end ?? structural.length)
|
|
1877
|
+
) {
|
|
1878
|
+
offsets.push({ at: i, ambiguous: true, name: ambiguousOwner });
|
|
1879
|
+
i = end ?? i + 1;
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
// A root directive's slots and a `where` named comparison never take
|
|
1883
|
+
// a script body through an unproven name: `:put x={ :beep }` and
|
|
1884
|
+
// `where gateway={ :beep }` are device rejections, so the brace reads
|
|
1885
|
+
// as a VALUE for the body check and the error below stands (#554).
|
|
1886
|
+
// Proven slots still skip — the gate is only the unproven disjunct.
|
|
1887
|
+
const unprovenName =
|
|
1888
|
+
directiveVerb !== undefined || inWhereComparison ? "value" : "body";
|
|
1787
1889
|
if (
|
|
1788
1890
|
// Asked of the DOCUMENT when the caller anchored this statement, so the
|
|
1789
1891
|
// statement index behind it is the document's one index rather than a
|
|
@@ -1791,19 +1893,22 @@ export function invalidCommandBraceOffsets(
|
|
|
1791
1893
|
// subtree, and so was rebuilt once per enclosing level (#322). The floor
|
|
1792
1894
|
// makes the two readings the same answer (`scope-brace.ts`).
|
|
1793
1895
|
(range === undefined
|
|
1794
|
-
? braceStartsStatements(structural, i)
|
|
1795
|
-
: braceStartsStatements(
|
|
1896
|
+
? braceStartsStatements(structural, i, 0, unprovenName)
|
|
1897
|
+
: braceStartsStatements(
|
|
1898
|
+
range.masked,
|
|
1899
|
+
range.start + i,
|
|
1900
|
+
range.start,
|
|
1901
|
+
unprovenName,
|
|
1902
|
+
)) ||
|
|
1796
1903
|
(directiveVerb !== undefined &&
|
|
1797
1904
|
slot !== undefined &&
|
|
1798
1905
|
braceSlotAcceptsBrace(directiveVerb, slot)) ||
|
|
1799
|
-
(directiveVerb === undefined &&
|
|
1800
|
-
whereAt !== null &&
|
|
1801
|
-
!comparesNamedValue(structural, i, whereAt))
|
|
1906
|
+
(directiveVerb === undefined && whereAt !== null && !inWhereComparison)
|
|
1802
1907
|
) {
|
|
1803
1908
|
i = end ?? structural.length;
|
|
1804
1909
|
continue;
|
|
1805
1910
|
}
|
|
1806
|
-
offsets.push(i);
|
|
1911
|
+
offsets.push({ at: i, ambiguous: false });
|
|
1807
1912
|
i = end ?? i + 1;
|
|
1808
1913
|
}
|
|
1809
1914
|
return offsets;
|