@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
|
@@ -22,9 +22,11 @@ re-proves it (CMR15 in `commands/execute/examples.md`):
|
|
|
22
22
|
| `read,test,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to the controller's own row; `/cmr/device/reboot` rebooted a client |
|
|
23
23
|
|
|
24
24
|
MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) this is fixed for 7.26beta2: a `read` group will not
|
|
25
|
-
be allowed to do it.
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
be allowed to do it. #527 now carries direct 7.26beta3 evidence that a
|
|
26
|
+
restricted caller is refused, with the first refusing release still
|
|
27
|
+
unbracketed. centrs still warns on any version, because the warning reads
|
|
28
|
+
the login's policy, not the RouterOS version. On a build that refuses the
|
|
29
|
+
write, the members fail
|
|
28
30
|
instead and the warning only says what the login lacks.
|
|
29
31
|
|
|
30
32
|
`context` names the login (`username`, `group`), the policies it lacks
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# `routeros/permission-denied`
|
|
2
|
+
|
|
3
|
+
RouterOS refused the command because the user's group lacks a policy it needs.
|
|
4
|
+
The router answered, and a retry gives the same result. A single refused
|
|
5
|
+
command did not run. A multi-statement script run through `/execute` can be
|
|
6
|
+
partly applied: statements before the refused one already ran.
|
|
7
|
+
|
|
8
|
+
## Typical RouterOS strings
|
|
9
|
+
|
|
10
|
+
- `not enough permissions (9)`: the command needs a policy the group does not
|
|
11
|
+
have, for example a `set` without `write`.
|
|
12
|
+
- `std failure: not allowed (9)`: on REST before RouterOS 7.26beta3, every
|
|
13
|
+
request fails this way (a plain GET included) when the group has `rest-api`
|
|
14
|
+
but not `api`. 7.26beta3 fixed it.
|
|
15
|
+
|
|
16
|
+
Over REST both arrive as HTTP 500 with the string in `detail`; over the native
|
|
17
|
+
API as a `!trap`. Grounded on CHR 7.23.8, 7.25, 7.26beta1 and 7.26beta3.
|
|
18
|
+
|
|
19
|
+
## Fix
|
|
20
|
+
|
|
21
|
+
Grant the missing policy to the user's group in `/user/group`: `read` for
|
|
22
|
+
reads, `write` for changes. Before 7.26beta3 a REST user needs both `api` and
|
|
23
|
+
`rest-api`. Avoid the built-in `read` group for a least-privilege user: it also
|
|
24
|
+
carries `reboot`, `sensitive`, `password` and others.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `transport/edge-auth-failed`
|
|
2
|
+
|
|
3
|
+
An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the
|
|
4
|
+
request before RouterOS answered it.
|
|
5
|
+
|
|
6
|
+
## How centrs tells
|
|
7
|
+
|
|
8
|
+
RouterOS answers every REST auth refusal with a JSON body whose `error` equals
|
|
9
|
+
the HTTP status (`{"error":401,"message":"Unauthorized"}`), adding
|
|
10
|
+
`WWW-Authenticate: Basic` only when no credentials were sent. A 401 or 403 with
|
|
11
|
+
any other body, or a challenge for another scheme (Cloudflare Access sends
|
|
12
|
+
`Bearer`), came from something in front of the router. `context` records the
|
|
13
|
+
challenge scheme, `server`, content type and whether a `cf-ray` header was
|
|
14
|
+
present. A `cf-ray` header alone does not decide it: a tunnel passes RouterOS's
|
|
15
|
+
own 401 through with one.
|
|
16
|
+
|
|
17
|
+
## Fix
|
|
18
|
+
|
|
19
|
+
centrs sends only RouterOS Basic credentials, so changing the RouterOS username
|
|
20
|
+
or password will not help. Point `--host` at an address that reaches RouterOS
|
|
21
|
+
directly, or at a local proxy that authenticates to the front layer for you.
|
|
22
|
+
The request never reached RouterOS, so a write was not applied.
|
|
@@ -9,6 +9,9 @@ whether the write happened.
|
|
|
9
9
|
|
|
10
10
|
- A connection closed or reset after an `execute` or `api` write was sent.
|
|
11
11
|
- A REST 5xx or a timeout on a write.
|
|
12
|
+
- A multi-statement `execute` or `api /execute` script refused with
|
|
13
|
+
`routeros/permission-denied`: statements before the refused one may already
|
|
14
|
+
have run (#549). A single refused statement did not run and warns nothing.
|
|
12
15
|
|
|
13
16
|
centrs never retries a write that it has sent, in single-target or fan-out
|
|
14
17
|
mode, because replaying an `add`, a script run or a counter change would apply
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# `usage/invalid-cursor`
|
|
2
|
+
|
|
3
|
+
An explain --cursor is not a byte offset inside the input being analyzed.
|
|
4
|
+
|
|
5
|
+
## Typical trigger
|
|
6
|
+
|
|
7
|
+
`centrs explain <router> '<input>' --complete --cursor <byte>` with a value that
|
|
8
|
+
is not a whole number, or that is larger than the input's UTF-8 byte length.
|
|
9
|
+
Library callers get the same code for an original-document position
|
|
10
|
+
(`{ line, character }`) that addresses no character in the input.
|
|
11
|
+
|
|
12
|
+
The check runs before any connection is made, so nothing is sent to the router.
|
|
13
|
+
|
|
14
|
+
## Fix
|
|
15
|
+
|
|
16
|
+
- Count the cursor in **UTF-8 bytes**, not characters: `é` is two bytes and
|
|
17
|
+
`🛰` is four. The end of the input (the default) is the input's byte length.
|
|
18
|
+
- A cursor inside a multi-byte character is accepted and moves back to that
|
|
19
|
+
character's first byte.
|
|
20
|
+
- Omit `--cursor` to complete or describe at the end of the input.
|
|
@@ -19,3 +19,12 @@ Two sources raise it:
|
|
|
19
19
|
|
|
20
20
|
Check the spelling of the segment in `error.context.segment`. `centrs explain
|
|
21
21
|
'<command>'` shows how the rest of the command reads offline.
|
|
22
|
+
|
|
23
|
+
One path is not a typo: `/system/routerboard` exists only on MikroTik
|
|
24
|
+
hardware, so `retrieve` answers its absence on CHR and x86 systems with a
|
|
25
|
+
targeted remediation instead of the generic one. For the device's license
|
|
26
|
+
ID, read `/system/license` (`system-id` on CHR, `software-id` on x86):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
centrs retrieve <router> /system/license
|
|
30
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/centrs",
|
|
3
|
-
"version": "0.1.9-next.
|
|
3
|
+
"version": "0.1.9-next.24",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"bin": {
|
|
41
41
|
"centrs": "src/cli.ts"
|
|
42
42
|
},
|
|
43
|
-
"description": "All-in-one TypeScript library/CLI/
|
|
43
|
+
"description": "All-in-one TypeScript library/CLI/MCP for interacting with MikroTik RouterOS devices (TUI stub)",
|
|
44
44
|
"engines": {
|
|
45
45
|
"bun": ">=1.3.11 <2"
|
|
46
46
|
},
|
package/src/api.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
promptForWriteConfirmation,
|
|
63
63
|
validateRouterOsScript,
|
|
64
64
|
} from "./execute.ts";
|
|
65
|
+
import { countExecutableStatements } from "./explain/segment.ts";
|
|
65
66
|
import {
|
|
66
67
|
assertOfflineSyntax,
|
|
67
68
|
isOfflineGateRejection,
|
|
@@ -541,7 +542,7 @@ async function* streamResolvedApi(
|
|
|
541
542
|
trace.validation = validation;
|
|
542
543
|
resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
|
|
543
544
|
} else {
|
|
544
|
-
validation =
|
|
545
|
+
validation = disabledApiValidationMeta(resolved);
|
|
545
546
|
}
|
|
546
547
|
} catch (error) {
|
|
547
548
|
await backend.close();
|
|
@@ -824,16 +825,6 @@ function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
|
|
|
824
825
|
});
|
|
825
826
|
}
|
|
826
827
|
|
|
827
|
-
function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
828
|
-
return {
|
|
829
|
-
enabled: false,
|
|
830
|
-
source: "disabled",
|
|
831
|
-
result: "skipped",
|
|
832
|
-
syntax: false,
|
|
833
|
-
semantic: false,
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
|
|
837
828
|
export async function resolveApiRequest(
|
|
838
829
|
request: ApiRequest,
|
|
839
830
|
env: Record<string, string | undefined>,
|
|
@@ -1347,7 +1338,8 @@ interface ApiValidationResult {
|
|
|
1347
1338
|
* validator is `/console/inspect`, never `:put [:parse]`. Path existence and
|
|
1348
1339
|
* add/set attribute validity both come from `request=child` — command-level
|
|
1349
1340
|
* `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
|
|
1350
|
-
* `/execute` script is a CLI string
|
|
1341
|
+
* `/execute` script is a CLI string, so it has no `semantic` summary; a
|
|
1342
|
+
* structured request runs no `:parse`, so it has no `syntax` summary (#535).
|
|
1351
1343
|
*/
|
|
1352
1344
|
async function validateApiRequest(
|
|
1353
1345
|
resolved: ResolvedApiRequest,
|
|
@@ -1383,8 +1375,7 @@ async function validateApiRequest(
|
|
|
1383
1375
|
? "not-applicable"
|
|
1384
1376
|
: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
1385
1377
|
result: "passed",
|
|
1386
|
-
syntax:
|
|
1387
|
-
semantic: "not-applicable",
|
|
1378
|
+
...(script !== undefined ? { syntax: true } : {}),
|
|
1388
1379
|
stages: [
|
|
1389
1380
|
{
|
|
1390
1381
|
stage: "offline",
|
|
@@ -1468,13 +1459,7 @@ async function validateApiRequest(
|
|
|
1468
1459
|
}
|
|
1469
1460
|
}
|
|
1470
1461
|
return {
|
|
1471
|
-
validation:
|
|
1472
|
-
enabled: true,
|
|
1473
|
-
source: "/console/inspect request=child",
|
|
1474
|
-
result: "passed",
|
|
1475
|
-
syntax: false,
|
|
1476
|
-
semantic: true,
|
|
1477
|
-
},
|
|
1462
|
+
validation: structuredValidationPassed(resolved),
|
|
1478
1463
|
tips,
|
|
1479
1464
|
queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
|
|
1480
1465
|
};
|
|
@@ -1513,11 +1498,7 @@ async function validateApiRequest(
|
|
|
1513
1498
|
}
|
|
1514
1499
|
return {
|
|
1515
1500
|
validation: {
|
|
1516
|
-
|
|
1517
|
-
source: "/console/inspect request=child",
|
|
1518
|
-
result: "passed",
|
|
1519
|
-
syntax: false,
|
|
1520
|
-
semantic: true,
|
|
1501
|
+
...structuredValidationPassed(resolved),
|
|
1521
1502
|
availableAttributes: available,
|
|
1522
1503
|
},
|
|
1523
1504
|
tips,
|
|
@@ -1526,13 +1507,7 @@ async function validateApiRequest(
|
|
|
1526
1507
|
}
|
|
1527
1508
|
|
|
1528
1509
|
return {
|
|
1529
|
-
validation:
|
|
1530
|
-
enabled: true,
|
|
1531
|
-
source: "/console/inspect request=child",
|
|
1532
|
-
result: "passed",
|
|
1533
|
-
syntax: false,
|
|
1534
|
-
semantic: true,
|
|
1535
|
-
},
|
|
1510
|
+
validation: structuredValidationPassed(resolved),
|
|
1536
1511
|
tips,
|
|
1537
1512
|
queryMenu: resolved.path,
|
|
1538
1513
|
};
|
|
@@ -1857,6 +1832,12 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1857
1832
|
trace?: ValidationTrace,
|
|
1858
1833
|
): ApiErrorEnvelope {
|
|
1859
1834
|
const centrsError = asApiError(error);
|
|
1835
|
+
// A structured request is one operation; only a `/execute` script can
|
|
1836
|
+
// carry several statements into one dispatch (#549), at the top level or
|
|
1837
|
+
// inside an executed control scope.
|
|
1838
|
+
const script = resolved.scriptMode ? scriptOf(resolved) : undefined;
|
|
1839
|
+
const multiStatement =
|
|
1840
|
+
script !== undefined && countExecutableStatements(script) > 1;
|
|
1860
1841
|
return {
|
|
1861
1842
|
ok: false,
|
|
1862
1843
|
error: serializeCentrsError(centrsError),
|
|
@@ -1866,6 +1847,7 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1866
1847
|
isApiMutating(resolved.method, resolved.path),
|
|
1867
1848
|
centrsError,
|
|
1868
1849
|
trace,
|
|
1850
|
+
multiStatement,
|
|
1869
1851
|
),
|
|
1870
1852
|
],
|
|
1871
1853
|
tips: [],
|
|
@@ -1877,6 +1859,22 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1877
1859
|
};
|
|
1878
1860
|
}
|
|
1879
1861
|
|
|
1862
|
+
/** A structured request the device stage passed; stage 1 does not apply to it. */
|
|
1863
|
+
function structuredValidationPassed(
|
|
1864
|
+
resolved: ResolvedApiRequest,
|
|
1865
|
+
): EnvelopeValidationMeta {
|
|
1866
|
+
return {
|
|
1867
|
+
enabled: true,
|
|
1868
|
+
source: API_DEVICE_STAGE_SOURCE,
|
|
1869
|
+
result: "passed",
|
|
1870
|
+
semantic: true,
|
|
1871
|
+
stages: [
|
|
1872
|
+
offlineApiStage(resolved),
|
|
1873
|
+
{ stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "passed" },
|
|
1874
|
+
],
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1880
1878
|
/**
|
|
1881
1879
|
* `meta.validation` for an api error envelope. A stage-1 rejection opened no
|
|
1882
1880
|
* connection, so the meta must not report `/console/inspect` as the validator
|
|
@@ -1896,7 +1894,6 @@ function failedApiValidationMeta(
|
|
|
1896
1894
|
source: OFFLINE_GATE_SOURCE,
|
|
1897
1895
|
result: "failed",
|
|
1898
1896
|
syntax: false,
|
|
1899
|
-
semantic: "not-applicable",
|
|
1900
1897
|
stages: [
|
|
1901
1898
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
1902
1899
|
{
|
|
@@ -1911,14 +1908,14 @@ function failedApiValidationMeta(
|
|
|
1911
1908
|
// Only a `validation/*` error identifies the device stage as the rejecter.
|
|
1912
1909
|
// Everything downstream of a passed gate — a RouterOS fault on the request,
|
|
1913
1910
|
// a transport drop — is served by `trace.validation` before this function is
|
|
1914
|
-
// ever reached.
|
|
1911
|
+
// ever reached. A script is refused by `:parse` (syntax); a structured
|
|
1912
|
+
// request by `/console/inspect` (names) (#535).
|
|
1915
1913
|
if (error.code.startsWith("validation/")) {
|
|
1916
1914
|
return {
|
|
1917
1915
|
enabled: true,
|
|
1918
1916
|
source: apiDeviceStageSource(resolved),
|
|
1919
1917
|
result: "failed",
|
|
1920
|
-
syntax: false,
|
|
1921
|
-
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1918
|
+
...(resolved.scriptMode ? { syntax: false } : { semantic: false }),
|
|
1922
1919
|
stages: [offlineApiStage(resolved, trace), deviceStageFailed(resolved)],
|
|
1923
1920
|
};
|
|
1924
1921
|
}
|
|
@@ -2012,8 +2009,6 @@ function disabledApiValidationMeta(
|
|
|
2012
2009
|
enabled: false,
|
|
2013
2010
|
source: "disabled",
|
|
2014
2011
|
result: "skipped",
|
|
2015
|
-
syntax: false,
|
|
2016
|
-
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
2017
2012
|
stages: [
|
|
2018
2013
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
2019
2014
|
{
|
package/src/cli/explain.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `centrs explain` CLI surface: command metadata, arg parsing, dispatch, and
|
|
3
|
-
* envelope rendering for
|
|
3
|
+
* envelope rendering for offline analysis and read-only live evidence (#491).
|
|
4
4
|
*
|
|
5
5
|
* ## The positional grammar is conditional, and that is deliberate
|
|
6
6
|
*
|
|
@@ -20,9 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* ## What this surface refuses to do
|
|
22
22
|
*
|
|
23
|
-
* - **
|
|
24
|
-
*
|
|
25
|
-
* report `mode: "offline"` about an invocation that asked for more.
|
|
23
|
+
* - **Live probes never execute the input.** The shared live module attaches
|
|
24
|
+
* device evidence, `--schema` and `--complete` to the offline result.
|
|
26
25
|
* - **Fan-out is rejected** (`usage/fanout-not-supported`, the `terminal`
|
|
27
26
|
* pattern): `explain` takes at most one router.
|
|
28
27
|
* - **`--complete` / `--schema` enumerate nothing offline.** They are accepted,
|
|
@@ -45,9 +44,17 @@ import {
|
|
|
45
44
|
renderExplainEnvelope,
|
|
46
45
|
resolveExplainFormat,
|
|
47
46
|
} from "../explain.ts";
|
|
47
|
+
import {
|
|
48
|
+
assertExplainTarget,
|
|
49
|
+
type ExplainLiveRequest,
|
|
50
|
+
explainLiveEnvelope,
|
|
51
|
+
} from "../explain-live.ts";
|
|
52
|
+
import { resolveExplainCursor } from "../explain-live-facets.ts";
|
|
48
53
|
import { describeCentrs } from "../index.ts";
|
|
49
54
|
import { loadEnvFileDefaults } from "../resolver/config-file.ts";
|
|
55
|
+
import { redactTargetInput } from "../resolver/index.ts";
|
|
50
56
|
import type { ResolvedSetting } from "../resolver/settings.ts";
|
|
57
|
+
import { apiCommand } from "./api.ts";
|
|
51
58
|
import {
|
|
52
59
|
type CliCommandMetadata,
|
|
53
60
|
expectValue,
|
|
@@ -59,10 +66,30 @@ import { selectionFlagTokens } from "./selection.ts";
|
|
|
59
66
|
|
|
60
67
|
export const explainCliCommand: CliCommandMetadata = {
|
|
61
68
|
name: "explain",
|
|
62
|
-
usage: "centrs explain '<input>' [flags]",
|
|
69
|
+
usage: "centrs explain [<router>] '<input>' [flags]",
|
|
63
70
|
summary:
|
|
64
|
-
"Analyze
|
|
71
|
+
"Analyze RouterOS input offline or add read-only device evidence from one router.",
|
|
65
72
|
options: [
|
|
73
|
+
...apiCommand.options.filter((o) =>
|
|
74
|
+
[
|
|
75
|
+
"--via",
|
|
76
|
+
"--host",
|
|
77
|
+
"--port",
|
|
78
|
+
"--username / --user / -u",
|
|
79
|
+
"--password",
|
|
80
|
+
"--timeout",
|
|
81
|
+
"--insecure",
|
|
82
|
+
"--cdb-file",
|
|
83
|
+
"--cdb-password",
|
|
84
|
+
"--resolve",
|
|
85
|
+
].includes(o.flag),
|
|
86
|
+
),
|
|
87
|
+
{ flag: "-p", valueName: "<secret>", description: "Alias for --password." },
|
|
88
|
+
{
|
|
89
|
+
flag: "--quickchr",
|
|
90
|
+
valueName: "<name>",
|
|
91
|
+
description: "Resolve one running quickchr VM by name.",
|
|
92
|
+
},
|
|
66
93
|
{
|
|
67
94
|
flag: "--file",
|
|
68
95
|
valueName: "<path>",
|
|
@@ -78,12 +105,23 @@ export const explainCliCommand: CliCommandMetadata = {
|
|
|
78
105
|
{
|
|
79
106
|
flag: "--complete",
|
|
80
107
|
description:
|
|
81
|
-
"
|
|
108
|
+
"Candidates at the cursor: menus and verbs, argument names, or values. Live evidence — offline emits a tip and enumerates nothing.",
|
|
82
109
|
},
|
|
83
110
|
{
|
|
84
111
|
flag: "--schema",
|
|
85
112
|
description:
|
|
86
|
-
"
|
|
113
|
+
"Describe the path at the cursor: a menu's verbs and submenus, a command's arguments, `print`'s proplist. Live evidence — offline emits a tip and enumerates nothing.",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
flag: "--cursor",
|
|
117
|
+
valueName: "<byte>",
|
|
118
|
+
description:
|
|
119
|
+
"UTF-8 byte offset for --complete/--schema. Default: the end of the input.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
flag: "--full",
|
|
123
|
+
description:
|
|
124
|
+
"Lift --complete/--schema sizing to its safety ceiling (1024 names, 128 expansion probes).",
|
|
87
125
|
},
|
|
88
126
|
{
|
|
89
127
|
flag: "--curl",
|
|
@@ -106,16 +144,20 @@ export const explainCliCommand: CliCommandMetadata = {
|
|
|
106
144
|
],
|
|
107
145
|
};
|
|
108
146
|
|
|
109
|
-
export interface ExplainCliArgs {
|
|
147
|
+
export interface ExplainCliArgs extends Omit<ExplainLiveRequest, "input"> {
|
|
110
148
|
help?: boolean;
|
|
111
149
|
positionals: string[];
|
|
112
150
|
filePath?: string;
|
|
113
151
|
failOn: ExplainFailOn;
|
|
114
152
|
facets: string[];
|
|
153
|
+
cursor?: number;
|
|
154
|
+
full?: boolean;
|
|
115
155
|
curl?: boolean;
|
|
116
156
|
tokens?: boolean;
|
|
117
157
|
format?: ExplainOutputFormat;
|
|
118
158
|
verbose?: boolean;
|
|
159
|
+
/** First explicitly supplied connection flag, for the missing-target refusal. */
|
|
160
|
+
connectionFlag?: string;
|
|
119
161
|
}
|
|
120
162
|
|
|
121
163
|
export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
@@ -133,6 +175,7 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
|
133
175
|
parsed.positionals.push(arg);
|
|
134
176
|
continue;
|
|
135
177
|
}
|
|
178
|
+
if (LIVE_CONNECTION_FLAGS.has(arg)) parsed.connectionFlag ??= arg;
|
|
136
179
|
switch (arg) {
|
|
137
180
|
case "--":
|
|
138
181
|
// Everything after `--` is input, however command-shaped it looks:
|
|
@@ -144,6 +187,54 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
|
144
187
|
case "-h":
|
|
145
188
|
parsed.help = true;
|
|
146
189
|
break;
|
|
190
|
+
case "--via":
|
|
191
|
+
parsed.via = expectValue(args, ++index, arg);
|
|
192
|
+
break;
|
|
193
|
+
case "--host":
|
|
194
|
+
parsed.host = expectValue(args, ++index, arg);
|
|
195
|
+
break;
|
|
196
|
+
case "--quickchr":
|
|
197
|
+
if (parsed.quickchr !== undefined)
|
|
198
|
+
throw fanoutNotSupportedError({ flag: arg });
|
|
199
|
+
parsed.quickchr = expectValue(args, ++index, arg);
|
|
200
|
+
break;
|
|
201
|
+
case "--timeout":
|
|
202
|
+
parsed.timeout = expectValue(args, ++index, arg);
|
|
203
|
+
break;
|
|
204
|
+
case "--cdb-file":
|
|
205
|
+
parsed.cdbFile = expectValue(args, ++index, arg);
|
|
206
|
+
break;
|
|
207
|
+
case "--cdb-password":
|
|
208
|
+
parsed.cdbPassword = expectValue(args, ++index, arg);
|
|
209
|
+
break;
|
|
210
|
+
case "--resolve":
|
|
211
|
+
parsed.resolve = expectValue(args, ++index, arg);
|
|
212
|
+
break;
|
|
213
|
+
case "--password":
|
|
214
|
+
parsed.password = expectValue(args, ++index, arg);
|
|
215
|
+
break;
|
|
216
|
+
case "-p":
|
|
217
|
+
parsed.password = expectValue(args, ++index, arg);
|
|
218
|
+
break;
|
|
219
|
+
case "-u":
|
|
220
|
+
case "--user":
|
|
221
|
+
case "--username":
|
|
222
|
+
parsed.username = expectValue(args, ++index, arg);
|
|
223
|
+
break;
|
|
224
|
+
case "--insecure":
|
|
225
|
+
parsed.insecure = true;
|
|
226
|
+
break;
|
|
227
|
+
case "--port": {
|
|
228
|
+
const value = expectValue(args, ++index, arg);
|
|
229
|
+
if (!/^\d+$/.test(value))
|
|
230
|
+
throw new CentrsError({
|
|
231
|
+
code: "input/invalid-command",
|
|
232
|
+
summary: "Port must be an integer.",
|
|
233
|
+
remediation: "Pass a TCP port between 1 and 65535.",
|
|
234
|
+
});
|
|
235
|
+
parsed.port = Number(value);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
147
238
|
case "--file":
|
|
148
239
|
parsed.filePath = expectValue(args, ++index, arg);
|
|
149
240
|
break;
|
|
@@ -167,6 +258,22 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
|
167
258
|
case "--schema":
|
|
168
259
|
parsed.facets.push("--schema");
|
|
169
260
|
break;
|
|
261
|
+
case "--cursor": {
|
|
262
|
+
const value = expectValue(args, ++index, arg);
|
|
263
|
+
if (!/^\d+$/.test(value))
|
|
264
|
+
throw new CentrsError({
|
|
265
|
+
code: "usage/invalid-cursor",
|
|
266
|
+
summary: `--cursor must be a non-negative byte offset; got ${value}.`,
|
|
267
|
+
remediation:
|
|
268
|
+
"Pass a UTF-8 byte offset into the input, or omit --cursor to complete at the end.",
|
|
269
|
+
context: { cursor: value },
|
|
270
|
+
});
|
|
271
|
+
parsed.cursor = Number(value);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "--full":
|
|
275
|
+
parsed.full = true;
|
|
276
|
+
break;
|
|
170
277
|
case "--curl":
|
|
171
278
|
parsed.curl = true;
|
|
172
279
|
break;
|
|
@@ -236,35 +343,11 @@ function fanoutNotSupportedError(context: {
|
|
|
236
343
|
...(context.flag === undefined ? {} : { flag: context.flag }),
|
|
237
344
|
...(context.targets === undefined
|
|
238
345
|
? {}
|
|
239
|
-
: { targets:
|
|
346
|
+
: { targets: context.targets.map(redactTargetInput) }),
|
|
240
347
|
},
|
|
241
348
|
});
|
|
242
349
|
}
|
|
243
350
|
|
|
244
|
-
/**
|
|
245
|
-
* The one live-form error.
|
|
246
|
-
*
|
|
247
|
-
* Named separately because it is a PHASE boundary, not a usage mistake: the
|
|
248
|
-
* invocation is the ratified grammar and will work unchanged when phase 2
|
|
249
|
-
* lands, so the remediation points at the offline form rather than at `--help`.
|
|
250
|
-
*/
|
|
251
|
-
function liveNotImplementedError(router: string): CentrsError {
|
|
252
|
-
// A "router" that starts `/` or `:` is almost always an unquoted input the
|
|
253
|
-
// shell split, so name that reading first rather than reporting a router
|
|
254
|
-
// nobody meant to give.
|
|
255
|
-
const looksLikeInput = /^[/:]/.test(router);
|
|
256
|
-
return new CentrsError({
|
|
257
|
-
code: "usage/not-implemented",
|
|
258
|
-
summary: looksLikeInput
|
|
259
|
-
? `Read \`${router}\` as a router, because two positionals mean \`<router> '<input>'\` — the input was probably not quoted.`
|
|
260
|
-
: `\`centrs explain <router> '<input>'\` needs live \`/console/inspect\` evidence, which is phase 2; the router \`${router}\` was not contacted.`,
|
|
261
|
-
remediation: looksLikeInput
|
|
262
|
-
? "Quote the whole input as one argument: `centrs explain '/ip/route print'`."
|
|
263
|
-
: "Drop the router for the offline analysis (`centrs explain '<input>'`), or use `rosetta` for documented RouterOS schema facts.",
|
|
264
|
-
context: { router, phase: "2" },
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
351
|
/**
|
|
269
352
|
* Is fd 0 attached to something that could be carrying input?
|
|
270
353
|
*
|
|
@@ -290,11 +373,8 @@ function stdinMayCarryInput(): boolean {
|
|
|
290
373
|
/**
|
|
291
374
|
* Split the positionals into targets and the one that may be the input.
|
|
292
375
|
*
|
|
293
|
-
* Pure and cheap
|
|
294
|
-
*
|
|
295
|
-
* `centrs explain router --file -` blocked on EOF before returning
|
|
296
|
-
* `usage/not-implemented`, and `… --file missing` reported a file error instead
|
|
297
|
-
* of the live refusal — the wrong answer, arrived at slowly.
|
|
376
|
+
* Pure and cheap: fan-out and unsupported facets are refused before any file
|
|
377
|
+
* or stdin is read. Supported live calls read the input before connecting.
|
|
298
378
|
*
|
|
299
379
|
* `--file` replaces the input positional, so with it every positional is a
|
|
300
380
|
* target. Otherwise the LAST positional is the input and everything before it is
|
|
@@ -320,7 +400,7 @@ function splitPositionals(parsed: ExplainCliArgs): {
|
|
|
320
400
|
}
|
|
321
401
|
|
|
322
402
|
/**
|
|
323
|
-
* Read the input text
|
|
403
|
+
* Read the input text after the invocation shape has been checked.
|
|
324
404
|
*
|
|
325
405
|
* ## Ambient stdin is read ONLY when nothing else could be the input
|
|
326
406
|
*
|
|
@@ -396,7 +476,40 @@ async function readFileInput(path: string): Promise<string> {
|
|
|
396
476
|
}
|
|
397
477
|
|
|
398
478
|
/** Flags whose NEXT token is a value, so a scanner must skip it. */
|
|
399
|
-
const VALUE_FLAGS = new Set([
|
|
479
|
+
const VALUE_FLAGS = new Set([
|
|
480
|
+
"--file",
|
|
481
|
+
"--fail-on",
|
|
482
|
+
"--format",
|
|
483
|
+
"--via",
|
|
484
|
+
"--host",
|
|
485
|
+
"--port",
|
|
486
|
+
"--username",
|
|
487
|
+
"--user",
|
|
488
|
+
"-u",
|
|
489
|
+
"--password",
|
|
490
|
+
"-p",
|
|
491
|
+
"--timeout",
|
|
492
|
+
"--cdb-file",
|
|
493
|
+
"--cdb-password",
|
|
494
|
+
"--resolve",
|
|
495
|
+
"--quickchr",
|
|
496
|
+
"--cursor",
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
const LIVE_CONNECTION_FLAGS = new Set([
|
|
500
|
+
"--via",
|
|
501
|
+
"--port",
|
|
502
|
+
"--username",
|
|
503
|
+
"--user",
|
|
504
|
+
"-u",
|
|
505
|
+
"--password",
|
|
506
|
+
"-p",
|
|
507
|
+
"--timeout",
|
|
508
|
+
"--insecure",
|
|
509
|
+
"--cdb-file",
|
|
510
|
+
"--cdb-password",
|
|
511
|
+
"--resolve",
|
|
512
|
+
]);
|
|
400
513
|
|
|
401
514
|
/**
|
|
402
515
|
* The render options, recovered when the parse loop THREW.
|
|
@@ -472,22 +585,68 @@ export async function runExplainCli(args: readonly string[]): Promise<number> {
|
|
|
472
585
|
const { targets, inputPositional } = splitPositionals(parsed);
|
|
473
586
|
if (targets.length > 1) throw fanoutNotSupportedError({ targets });
|
|
474
587
|
const router = targets[0];
|
|
475
|
-
|
|
588
|
+
assertExplainTarget({ targetInput: router, quickchr: parsed.quickchr });
|
|
589
|
+
if (router !== undefined && (/^\//.test(router) || /^:[a-z]/i.test(router)))
|
|
590
|
+
throw new CentrsError({
|
|
591
|
+
code: "input/invalid-command",
|
|
592
|
+
summary:
|
|
593
|
+
"The input was probably not quoted; two positionals mean router then input.",
|
|
594
|
+
remediation:
|
|
595
|
+
"Quote the whole input as one argument: `centrs explain '/ip/route print'`.",
|
|
596
|
+
});
|
|
597
|
+
const live =
|
|
598
|
+
router !== undefined ||
|
|
599
|
+
parsed.quickchr !== undefined ||
|
|
600
|
+
parsed.host !== undefined;
|
|
601
|
+
if (!live && parsed.connectionFlag !== undefined)
|
|
602
|
+
throw new CentrsError({
|
|
603
|
+
code: "input/invalid-command",
|
|
604
|
+
summary: `${parsed.connectionFlag} requires a router target for live explain.`,
|
|
605
|
+
remediation:
|
|
606
|
+
"Add <router>, --host <host>, or --quickchr <name>; remove the connection flag for offline analysis.",
|
|
607
|
+
context: { flag: parsed.connectionFlag },
|
|
608
|
+
});
|
|
609
|
+
if (
|
|
610
|
+
parsed.facets.length === 0 &&
|
|
611
|
+
(parsed.cursor !== undefined || parsed.full)
|
|
612
|
+
)
|
|
613
|
+
throw new CentrsError({
|
|
614
|
+
code: "usage/conflicting-flags",
|
|
615
|
+
summary: `${parsed.cursor !== undefined ? "--cursor" : "--full"} applies only with --complete or --schema.`,
|
|
616
|
+
remediation: "Add --complete or --schema, or remove the flag.",
|
|
617
|
+
});
|
|
476
618
|
|
|
477
619
|
const { input, warnings } = await readInput(parsed, inputPositional);
|
|
620
|
+
resolveExplainCursor(input, parsed.cursor);
|
|
478
621
|
|
|
479
|
-
const
|
|
622
|
+
const options = {
|
|
480
623
|
format,
|
|
481
624
|
curl: parsed.curl,
|
|
482
625
|
tokens: parsed.tokens,
|
|
483
626
|
warnings,
|
|
484
627
|
tips:
|
|
485
|
-
parsed.facets.length > 0
|
|
628
|
+
!live && parsed.facets.length > 0
|
|
486
629
|
? [buildExplainFacetTip(parsed.facets)]
|
|
487
630
|
: undefined,
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
631
|
+
};
|
|
632
|
+
const envelope = live
|
|
633
|
+
? await explainLiveEnvelope(
|
|
634
|
+
{
|
|
635
|
+
...parsed,
|
|
636
|
+
targetInput: router,
|
|
637
|
+
input,
|
|
638
|
+
schema: parsed.facets.includes("--schema"),
|
|
639
|
+
complete: parsed.facets.includes("--complete"),
|
|
640
|
+
},
|
|
641
|
+
options,
|
|
642
|
+
env,
|
|
643
|
+
)
|
|
644
|
+
: explainEnvelope(input, options);
|
|
645
|
+
if (envelope.ok) console.log(renderExplainEnvelope(envelope, format.value));
|
|
646
|
+
else console.error(renderExplainEnvelope(envelope, format.value));
|
|
647
|
+
return envelope.ok
|
|
648
|
+
? explainExitCode(envelope.data.verdict, parsed.failOn)
|
|
649
|
+
: 1;
|
|
491
650
|
} catch (error) {
|
|
492
651
|
// Only here is the parser's answer unavailable — it threw. `config` is
|
|
493
652
|
// whatever loaded before the throw, which is `{}` when the loader itself is
|