@tikoci/centrs 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +256 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +123 -41
- package/commands/api/examples.md +205 -17
- package/commands/devices/README.md +11 -3
- package/commands/execute/README.md +31 -3
- package/commands/execute/examples.md +136 -1
- package/commands/explain/README.md +30 -4
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +422 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +28 -20
- package/docs/CONSTITUTION.md +30 -10
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +511 -186
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +223 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +56 -1
- package/src/core/inspect.ts +43 -5
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +1 -1
- package/src/devices.ts +6 -9
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +13 -8
- package/src/execute.ts +106 -65
- package/src/explain/catalog.ts +58 -10
- package/src/explain/menus.ts +23 -2
- package/src/explain.ts +6 -6
- package/src/index.ts +29 -1
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +14 -17
- package/src/protocols/adapter.ts +224 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +6 -3
- package/src/protocols/native-api.ts +223 -70
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +53 -7
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +732 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `validation/menu-unavailable`
|
|
2
|
+
|
|
3
|
+
RouterOS 7 publishes this menu or command, but this device does not have it —
|
|
4
|
+
usually hardware, build or version.
|
|
5
|
+
|
|
6
|
+
Same detection as
|
|
7
|
+
[`validation/package-missing`](package-missing.md): RouterOS rejected a path
|
|
8
|
+
segment, and centrs's path catalog knows the path. This code is used when the
|
|
9
|
+
published gates are not a package alone — a hardware capability
|
|
10
|
+
(`/interface/w60g` needs `60ghz`), a build condition, or no gate at all, which
|
|
11
|
+
usually means the device's RouterOS version predates or dropped the menu.
|
|
12
|
+
The path can end in a command rather than a menu: `/system/license/output` is
|
|
13
|
+
published `nochr`, so a CHR rejects it here, and the summary calls it a command.
|
|
14
|
+
|
|
15
|
+
`error.context` carries `path`, `segment`, `gates` (root-first, each with the
|
|
16
|
+
`package`, `syscap` and `conditions` MikroTik published) and `detail`.
|
|
17
|
+
|
|
18
|
+
## Fix
|
|
19
|
+
|
|
20
|
+
The command is not the problem. Compare the gates with the device: installed
|
|
21
|
+
packages (`/system/package/print`), model, and RouterOS version
|
|
22
|
+
(`/system/resource/print`). Run the command only on devices that have the menu.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# `validation/not-followable`
|
|
2
|
+
|
|
3
|
+
The RouterOS menu cannot be followed: it is a single record, or its print has
|
|
4
|
+
no follow-only.
|
|
5
|
+
|
|
6
|
+
## When it happens
|
|
7
|
+
|
|
8
|
+
`centrs retrieve <router> <menu> --follow` checks the menu live through
|
|
9
|
+
`/console/inspect` before following it. RouterOS can follow a menu only when
|
|
10
|
+
its `print` takes `follow-only`, which is the same thing native-API `listen`
|
|
11
|
+
needs. Single-record menus such as `/system/resource`, `/system/identity` and
|
|
12
|
+
`/ip/dns` never take it, and neither do some list menus.
|
|
13
|
+
|
|
14
|
+
Nothing was followed. The error's `context.singleton` says which case it was.
|
|
15
|
+
|
|
16
|
+
## Fix
|
|
17
|
+
|
|
18
|
+
- Read the menu once: `centrs retrieve <router> <menu>`.
|
|
19
|
+
- To see a value change over time, sample it:
|
|
20
|
+
`centrs retrieve <router> <menu> --sample 5s` (one line per complete read,
|
|
21
|
+
until `--count`, `--duration` or Ctrl-C).
|
|
22
|
+
- If you expected the menu to be followable, check the path with
|
|
23
|
+
`centrs retrieve <router> <menu> --list-attributes`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# `validation/package-missing`
|
|
2
|
+
|
|
3
|
+
This device has no such menu or command; MikroTik publishes it in a package
|
|
4
|
+
that is likely not installed.
|
|
5
|
+
|
|
6
|
+
RouterOS's `:parse` reports a menu it does not have as `syntax error` (slash
|
|
7
|
+
spelling, `/zerotier/print`) or `bad command name` (space spelling,
|
|
8
|
+
`/zerotier print`). Both point at the segment it could not resolve. centrs
|
|
9
|
+
looks that path up in its path catalog — every RouterOS 7.10–7.25 build, all
|
|
10
|
+
extra packages — and when MikroTik publishes it behind a package and nothing
|
|
11
|
+
else, reports this code instead of `validation/syntax`.
|
|
12
|
+
|
|
13
|
+
`error.context` carries:
|
|
14
|
+
|
|
15
|
+
- `path` — the absent menu or command, from the root (`/zerotier`). The summary
|
|
16
|
+
says which, from the path catalog's kind. An abbreviated segment
|
|
17
|
+
is expanded when the catalog has one unique completion.
|
|
18
|
+
- `segment` — the segment as written.
|
|
19
|
+
- `packages` — the published package names. These are MikroTik's build names
|
|
20
|
+
and can differ from the installable package (`wireless-rep` ships in
|
|
21
|
+
`wireless`, `userman-5` in `user-manager`).
|
|
22
|
+
- `gates` — every published gate on the path, root-first.
|
|
23
|
+
- `detail` — RouterOS's own words; `error.position` is its byte column.
|
|
24
|
+
|
|
25
|
+
## Fix
|
|
26
|
+
|
|
27
|
+
The command is not the problem. Check the installed packages with
|
|
28
|
+
`/system/package/print`, install the package and reboot, or run the command only
|
|
29
|
+
on devices that have it.
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
# `validation/unknown-path`
|
|
2
2
|
|
|
3
|
-
The RouterOS path does not expose the requested command.
|
|
3
|
+
The RouterOS path does not exist, or does not expose the requested command.
|
|
4
|
+
|
|
5
|
+
Two sources raise it:
|
|
6
|
+
|
|
7
|
+
- **`execute` / `validate`** — RouterOS's `:parse` rejected a path segment, and
|
|
8
|
+
the path is in no published RouterOS 7 build centrs knows of (its path
|
|
9
|
+
catalog covers 7.10–7.25, all extra packages). Usually a typo.
|
|
10
|
+
`error.context.path` is the path through the rejected segment,
|
|
11
|
+
`error.context.segment` the segment as written, and `error.position`
|
|
12
|
+
RouterOS's byte column. A path the catalog does know is reported as
|
|
13
|
+
[`validation/package-missing`](package-missing.md) or
|
|
14
|
+
[`validation/menu-unavailable`](menu-unavailable.md) instead.
|
|
15
|
+
- **`retrieve` / `api`** — `/console/inspect` shows the path has no command of
|
|
16
|
+
the kind requested (no `print` or `get` to read with).
|
|
4
17
|
|
|
5
18
|
## Fix
|
|
6
19
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
for `validation/unknown-path`.
|
|
20
|
+
Check the spelling of the segment in `error.context.segment`. `centrs explain
|
|
21
|
+
'<command>'` shows how the rest of the command reads offline.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# `wait/deadline-exceeded`
|
|
2
|
+
|
|
3
|
+
The wait deadline passed before readiness or its condition was established.
|
|
4
|
+
|
|
5
|
+
## Fix
|
|
6
|
+
|
|
7
|
+
Check the predicate and the router's current state, then choose a longer
|
|
8
|
+
`--wait` if the operation legitimately needs more time. `--timeout` bounds
|
|
9
|
+
individual requests and does not extend the overall deadline.
|
|
10
|
+
|
|
11
|
+
`meta.operation.wait` reports `deadline-elapsed`, elapsed milliseconds,
|
|
12
|
+
attempts and completed observations. A failed or unfinished read never proves
|
|
13
|
+
an empty result. If the last attempt failed with a transient transport error,
|
|
14
|
+
that error is preserved instead of this code.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# `wait/interrupted`
|
|
2
|
+
|
|
3
|
+
The wait was cancelled before readiness or its condition was established.
|
|
4
|
+
|
|
5
|
+
## Fix
|
|
6
|
+
|
|
7
|
+
Run the wait again when you want to continue. Ctrl-C or an aborted library
|
|
8
|
+
signal is a failure to establish the condition, even if some reads completed.
|
|
9
|
+
The final envelope reports `meta.operation.wait.stopReason: "interrupted"`
|
|
10
|
+
and the partial attempts and observations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/centrs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"repository": {
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"packageManager": "bun@1.3.14",
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@biomejs/biome": "2.5.
|
|
16
|
-
"@secretlint/secretlint-rule-preset-recommend": "^13.0.
|
|
17
|
-
"@types/bun": "
|
|
15
|
+
"@biomejs/biome": "2.5.15",
|
|
16
|
+
"@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
|
|
17
|
+
"@types/bun": "1.3.14",
|
|
18
18
|
"cloc": "^2.6.0-cloc",
|
|
19
|
-
"cspell": "^10.
|
|
19
|
+
"cspell": "^10.3.6",
|
|
20
20
|
"has-flag": "4.0.0",
|
|
21
|
-
"markdownlint-cli2": "^0.23.
|
|
22
|
-
"secretlint": "^13.0.
|
|
23
|
-
"typedoc": "^0.28.
|
|
21
|
+
"markdownlint-cli2": "^0.23.3",
|
|
22
|
+
"secretlint": "^13.0.7",
|
|
23
|
+
"typedoc": "^0.28.20"
|
|
24
24
|
},
|
|
25
25
|
"optionalDependencies": {
|
|
26
|
-
"@tikoci/quickchr": "^0.4.
|
|
26
|
+
"@tikoci/quickchr": "^0.4.8"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"typescript": "^6.0.3"
|
|
@@ -133,10 +133,13 @@
|
|
|
133
133
|
"explain:token-census:readme:check": "bun run scripts/explain-token-census.ts --readme --check",
|
|
134
134
|
"security": "bun run lint:secretlint && bun run lint:markdownlint"
|
|
135
135
|
},
|
|
136
|
+
"overrides": {
|
|
137
|
+
"smol-toml": "^1.9.1"
|
|
138
|
+
},
|
|
136
139
|
"type": "module",
|
|
137
140
|
"types": "src/index.ts",
|
|
138
141
|
"dependencies": {
|
|
139
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
140
|
-
"zod": "^4.
|
|
142
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
143
|
+
"zod": "^4.6.7"
|
|
141
144
|
}
|
|
142
145
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a menu this device does not have apart from a real syntax error (GH#361).
|
|
3
|
+
*
|
|
4
|
+
* RouterOS's `:parse` reports an absent menu as if it were a syntax fault, in two
|
|
5
|
+
* spellings that both name the segment it could not resolve (CHR 7.23.7):
|
|
6
|
+
*
|
|
7
|
+
* /zerotier/print -> syntax error (line 1 column 10)
|
|
8
|
+
* /zerotier print -> (<%% bad command name zerotier (line 1 column 2) …)
|
|
9
|
+
*
|
|
10
|
+
* The slash spelling points at the `/` right AFTER the absent segment; the space
|
|
11
|
+
* spelling names the segment and points at its first byte. The column is a 1-based
|
|
12
|
+
* BYTE column on the given line (`:put "é"; /zerotier/print` -> column 21, not 20).
|
|
13
|
+
* Nesting moves the column but not the rule: `:put [/interface/w60g/get …]`,
|
|
14
|
+
* `/ip/address { /zerotier/print }` and a second line all point at the `/` after
|
|
15
|
+
* the absent segment.
|
|
16
|
+
*
|
|
17
|
+
* Once the segment is located, the path catalog says what it is. Both shipped
|
|
18
|
+
* tables are built from all-extra-packages builds, so a path they carry exists in
|
|
19
|
+
* SOME RouterOS 7 build, and its published gates explain why this one lacks it:
|
|
20
|
+
*
|
|
21
|
+
* The path can end in a command (`/system/license/output`, gated `nochr`), not
|
|
22
|
+
* only a menu; the summary uses the catalog's kind.
|
|
23
|
+
*
|
|
24
|
+
* - a package gate alone -> `validation/package-missing`
|
|
25
|
+
* - any other gate, or none -> `validation/menu-unavailable`
|
|
26
|
+
* - not in either table -> `validation/unknown-path`
|
|
27
|
+
*
|
|
28
|
+
* Anything this cannot locate exactly returns `undefined`, and the caller keeps
|
|
29
|
+
* its `validation/syntax`. Abstaining is always safe; guessing a path is not.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { CentrsError, type RouterOsErrorPosition } from "./errors.ts";
|
|
33
|
+
import {
|
|
34
|
+
type CatalogGate,
|
|
35
|
+
effectiveGates,
|
|
36
|
+
PATH_CATALOG,
|
|
37
|
+
} from "./explain/catalog.ts";
|
|
38
|
+
import { MENU_PATHS } from "./explain/menus.ts";
|
|
39
|
+
import { explainCommand } from "./explain.ts";
|
|
40
|
+
|
|
41
|
+
const NAME = /[a-z0-9-]/i;
|
|
42
|
+
const BAD_COMMAND_NAME = /bad command name\s+(\S+)\s+\(line \d+ column \d+\)/i;
|
|
43
|
+
const BARE_SYNTAX_ERROR = /^[ \t]*syntax error \(line \d+ column \d+\)/im;
|
|
44
|
+
/** What may precede the leading `/` of an absolute path run. */
|
|
45
|
+
const RUN_BOUNDARY = /[\s[{;(]/;
|
|
46
|
+
|
|
47
|
+
/** The located absent segment, as a path from the root. */
|
|
48
|
+
interface AbsentPath {
|
|
49
|
+
/** Lower-cased segments from the root through the absent one. */
|
|
50
|
+
segments: string[];
|
|
51
|
+
/** The segment RouterOS could not resolve, as written. */
|
|
52
|
+
segment: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function absentMenuError(
|
|
56
|
+
cli: string,
|
|
57
|
+
output: string,
|
|
58
|
+
position: RouterOsErrorPosition | undefined,
|
|
59
|
+
via: string,
|
|
60
|
+
): CentrsError | undefined {
|
|
61
|
+
if (position === undefined) return undefined;
|
|
62
|
+
const offset = offsetOf(cli, position);
|
|
63
|
+
if (offset === undefined) return undefined;
|
|
64
|
+
const absent = locateAbsentPath(cli, output, offset);
|
|
65
|
+
if (absent === undefined) return undefined;
|
|
66
|
+
|
|
67
|
+
const segments = expandAbbreviations(absent.segments);
|
|
68
|
+
const path = `/${segments.join("/")}`;
|
|
69
|
+
const context = {
|
|
70
|
+
command: cli,
|
|
71
|
+
path,
|
|
72
|
+
segment: absent.segment,
|
|
73
|
+
validationSource: `:put [:parse ...] over ${via}`,
|
|
74
|
+
detail: output,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (!isPublished(path)) {
|
|
78
|
+
return new CentrsError({
|
|
79
|
+
code: "validation/unknown-path",
|
|
80
|
+
summary: `RouterOS has no \`${path}\` menu or command, and centrs's path catalog has none.`,
|
|
81
|
+
remediation: `Check the spelling of \`${absent.segment}\`: the catalog covers every RouterOS 7.10–7.25 build with all extra packages. \`centrs explain '<command>'\` shows how the rest of the command reads.`,
|
|
82
|
+
context,
|
|
83
|
+
position,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const noun = PATH_CATALOG.get(path)?.kind === "command" ? "command" : "menu";
|
|
88
|
+
const gates = gatesOf(segments);
|
|
89
|
+
const packages = unique(gates.flatMap((gate) => gate.package ?? []));
|
|
90
|
+
const others = gates.filter(
|
|
91
|
+
(gate) => gate.conditions !== undefined || gate.syscap !== undefined,
|
|
92
|
+
);
|
|
93
|
+
if (packages.length > 0 && others.length === 0) {
|
|
94
|
+
const named = packages.map((name) => `\`${name}\``).join(", ");
|
|
95
|
+
return new CentrsError({
|
|
96
|
+
code: "validation/package-missing",
|
|
97
|
+
summary: `This device has no \`${path}\` ${noun}; MikroTik publishes it in package ${named}, which is likely not installed.`,
|
|
98
|
+
remediation: `The command is not the problem. Check the installed packages with \`/system/package/print\`; install ${named} (MikroTik's published name; the installable package can be named differently) and reboot, or run this only on devices that have it.`,
|
|
99
|
+
context: { ...context, packages, gates },
|
|
100
|
+
position,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return new CentrsError({
|
|
104
|
+
code: "validation/menu-unavailable",
|
|
105
|
+
summary: `This device has no \`${path}\` ${noun}, although RouterOS 7 publishes one.`,
|
|
106
|
+
remediation: `The command is not the problem. ${gateAdvice(gates)}`,
|
|
107
|
+
context: { ...context, gates },
|
|
108
|
+
position,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The UTF-16 index of RouterOS's 1-based line / byte column, or `undefined`. */
|
|
113
|
+
function offsetOf(
|
|
114
|
+
cli: string,
|
|
115
|
+
{ line, column }: RouterOsErrorPosition,
|
|
116
|
+
): number | undefined {
|
|
117
|
+
const lines = cli.split("\n");
|
|
118
|
+
const text = lines[line - 1];
|
|
119
|
+
if (text === undefined || column < 1) return undefined;
|
|
120
|
+
const bytes = new TextEncoder().encode(text);
|
|
121
|
+
if (column - 1 > bytes.length) return undefined;
|
|
122
|
+
const prefix = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
123
|
+
bytes.slice(0, column - 1),
|
|
124
|
+
);
|
|
125
|
+
let start = 0;
|
|
126
|
+
for (let index = 0; index < line - 1; index++)
|
|
127
|
+
start += (lines[index] as string).length + 1;
|
|
128
|
+
return start + prefix.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function locateAbsentPath(
|
|
132
|
+
cli: string,
|
|
133
|
+
output: string,
|
|
134
|
+
offset: number,
|
|
135
|
+
): AbsentPath | undefined {
|
|
136
|
+
const named = output.match(BAD_COMMAND_NAME);
|
|
137
|
+
if (named) {
|
|
138
|
+
const segment = named[1] as string;
|
|
139
|
+
if (cli.slice(offset, offset + segment.length) !== segment)
|
|
140
|
+
return undefined;
|
|
141
|
+
return cli[offset - 1] === "/"
|
|
142
|
+
? slashRun(cli, offset, offset + segment.length)
|
|
143
|
+
: spaceSpelling(cli, offset, segment);
|
|
144
|
+
}
|
|
145
|
+
if (!BARE_SYNTAX_ERROR.test(output) || cli[offset] !== "/") return undefined;
|
|
146
|
+
let start = offset;
|
|
147
|
+
while (start > 0 && NAME.test(cli[start - 1] as string)) start--;
|
|
148
|
+
if (start === offset || cli[start - 1] !== "/") return undefined;
|
|
149
|
+
return slashRun(cli, start, offset);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `/a/b/<segment>` — walk back over `name/` pairs to the run's leading `/`, which
|
|
154
|
+
* must sit at a statement boundary. A relative run (`../x`, `ip/x`) abstains.
|
|
155
|
+
*/
|
|
156
|
+
function slashRun(
|
|
157
|
+
cli: string,
|
|
158
|
+
start: number,
|
|
159
|
+
end: number,
|
|
160
|
+
): AbsentPath | undefined {
|
|
161
|
+
let runStart = start - 1;
|
|
162
|
+
while (runStart > 0 && cli[runStart] === "/") {
|
|
163
|
+
let nameStart = runStart;
|
|
164
|
+
while (nameStart > 0 && NAME.test(cli[nameStart - 1] as string))
|
|
165
|
+
nameStart--;
|
|
166
|
+
if (nameStart === runStart) break;
|
|
167
|
+
if (cli[nameStart - 1] !== "/") return undefined;
|
|
168
|
+
runStart = nameStart - 1;
|
|
169
|
+
}
|
|
170
|
+
if (runStart > 0 && !RUN_BOUNDARY.test(cli[runStart - 1] as string))
|
|
171
|
+
return undefined;
|
|
172
|
+
const segments = cli
|
|
173
|
+
.slice(runStart + 1, end)
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.split("/");
|
|
176
|
+
return { segments, segment: cli.slice(start, end) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `/ip nosuchmenu print` — the parent comes from the offline analyzer's reading
|
|
181
|
+
* of the statement that holds the segment. Menu-scope blocks and anything else
|
|
182
|
+
* the analyzer did not resolve abstain, and so does a segment that appears more
|
|
183
|
+
* than once (`/ip ip print`): which occurrence RouterOS rejected is not mapped.
|
|
184
|
+
*/
|
|
185
|
+
function spaceSpelling(
|
|
186
|
+
cli: string,
|
|
187
|
+
offset: number,
|
|
188
|
+
segment: string,
|
|
189
|
+
): AbsentPath | undefined {
|
|
190
|
+
const { structure } = explainCommand(cli);
|
|
191
|
+
const holders = [...structure.statements, ...structure.subcommands]
|
|
192
|
+
.filter(
|
|
193
|
+
(entry) =>
|
|
194
|
+
entry.resolution === "resolved" &&
|
|
195
|
+
entry.command !== undefined &&
|
|
196
|
+
entry.span.start <= offset &&
|
|
197
|
+
offset < entry.span.end,
|
|
198
|
+
)
|
|
199
|
+
.sort((a, b) => a.span.end - a.span.start - (b.span.end - b.span.start));
|
|
200
|
+
const command = holders[0]?.command;
|
|
201
|
+
if (command === undefined) return undefined;
|
|
202
|
+
const words = [
|
|
203
|
+
...command.path.split("/").filter(Boolean),
|
|
204
|
+
...("verb" in command ? [command.verb] : []),
|
|
205
|
+
].map((word) => word.toLowerCase());
|
|
206
|
+
const index = words.indexOf(segment.toLowerCase());
|
|
207
|
+
if (index < 0 || words.lastIndexOf(segment.toLowerCase()) !== index)
|
|
208
|
+
return undefined;
|
|
209
|
+
return { segments: words.slice(0, index + 1), segment };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** RouterOS accepts a unique prefix; resolve one against the catalog's children. */
|
|
213
|
+
function expandAbbreviations(segments: readonly string[]): string[] {
|
|
214
|
+
const out: string[] = [];
|
|
215
|
+
for (const segment of segments) {
|
|
216
|
+
const parent = out.length === 0 ? "" : `/${out.join("/")}`;
|
|
217
|
+
if (isPublished(`${parent}/${segment}`)) {
|
|
218
|
+
out.push(segment);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const children = new Set<string>();
|
|
222
|
+
for (const path of publishedPaths())
|
|
223
|
+
if (path.startsWith(`${parent}/${segment}`)) {
|
|
224
|
+
const child = path.slice(parent.length + 1).split("/")[0] as string;
|
|
225
|
+
children.add(child);
|
|
226
|
+
}
|
|
227
|
+
out.push(children.size === 1 ? ([...children][0] as string) : segment);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function* publishedPaths(): Iterable<string> {
|
|
233
|
+
yield* PATH_CATALOG.keys();
|
|
234
|
+
yield* MENU_PATHS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A path, or anything under it, is in one of the two shipped tables. */
|
|
238
|
+
function isPublished(path: string): boolean {
|
|
239
|
+
if (PATH_CATALOG.has(path) || MENU_PATHS.has(path)) return true;
|
|
240
|
+
for (const known of publishedPaths())
|
|
241
|
+
if (known.startsWith(`${path}/`)) return true;
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The path's own gates, root-first. A container row the publication left ungated
|
|
247
|
+
* (`/iot`) borrows the package every catalog row under it agrees on — all 62
|
|
248
|
+
* `/iot/*` rows say `iot` — and borrows nothing when they disagree.
|
|
249
|
+
*/
|
|
250
|
+
function gatesOf(segments: readonly string[]): CatalogGate[] {
|
|
251
|
+
const gates = effectiveGates(segments);
|
|
252
|
+
if (gates.some((gate) => gate.package !== undefined)) return gates;
|
|
253
|
+
const path = `/${segments.join("/")}`;
|
|
254
|
+
const below = [...PATH_CATALOG].filter(([known]) =>
|
|
255
|
+
known.startsWith(`${path}/`),
|
|
256
|
+
);
|
|
257
|
+
const packages = unique(below.map(([, entry]) => entry.package ?? ""));
|
|
258
|
+
if (below.length > 0 && packages.length === 1 && packages[0] !== "")
|
|
259
|
+
return [...gates, { path, package: packages[0] as string }];
|
|
260
|
+
return gates;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function gateAdvice(gates: readonly CatalogGate[]): string {
|
|
264
|
+
if (gates.length === 0)
|
|
265
|
+
return "MikroTik publishes no package or hardware requirement for it, so the usual cause is the RouterOS version: compare `/system/resource/print` with the version that introduced the menu.";
|
|
266
|
+
const parts = gates.map((gate) =>
|
|
267
|
+
[
|
|
268
|
+
gate.package && `package \`${gate.package}\``,
|
|
269
|
+
gate.syscap && `hardware capability \`${gate.syscap}\``,
|
|
270
|
+
gate.conditions && `build \`${gate.conditions}\``,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join(" and "),
|
|
274
|
+
);
|
|
275
|
+
return `MikroTik publishes it as needing ${unique(parts).join("; ")}. Check \`/system/package/print\` and the device model, or run this only on devices that have it.`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function unique(values: readonly string[]): string[] {
|
|
279
|
+
return [...new Set(values)];
|
|
280
|
+
}
|
package/src/api-fanout.ts
CHANGED
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
* with `target/unresolved`.
|
|
16
16
|
* - A mutating fan-out is confirmed ONCE up front (`--yes`), never per target;
|
|
17
17
|
* without `--yes` the error names the blast radius (how many routers).
|
|
18
|
-
* - `--
|
|
18
|
+
* - `--stream` and `--raw` are rejected in fan-out mode by the CLI
|
|
19
19
|
* before reaching here (single-session / envelope-stripping).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
type ApiEnvelope,
|
|
24
24
|
type ApiOperationMeta,
|
|
25
|
+
type ApiOutputFormat,
|
|
25
26
|
type ApiRequest,
|
|
26
27
|
type ApiRequestSummary,
|
|
27
28
|
type ApiSuccessEnvelope,
|
|
@@ -51,7 +52,12 @@ import {
|
|
|
51
52
|
summarizeFanout,
|
|
52
53
|
} from "./core/fanout.ts";
|
|
53
54
|
import { toYaml } from "./core/yaml.ts";
|
|
54
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
CentrsError,
|
|
57
|
+
formatCentrsErrorText,
|
|
58
|
+
serializeCentrsError,
|
|
59
|
+
terminalSafeLine,
|
|
60
|
+
} from "./errors.ts";
|
|
55
61
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
56
62
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
57
63
|
import {
|
|
@@ -428,26 +434,26 @@ export function buildApiFanoutErrorEnvelope(
|
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
/**
|
|
431
|
-
* Render an api fan-out envelope. `json`/`yaml` serialize the whole envelope
|
|
432
|
-
* `text` renders a summary line plus one line
|
|
437
|
+
* Render an api fan-out envelope. `json`/`yaml` serialize the whole envelope,
|
|
438
|
+
* `ndjson` as one compact line; `text` renders a summary line plus one line
|
|
439
|
+
* per target in record-index order.
|
|
433
440
|
*/
|
|
434
441
|
export function renderApiFanoutEnvelope(
|
|
435
442
|
envelope: ApiFanoutEnvelope | ApiFanoutErrorEnvelope,
|
|
436
|
-
format:
|
|
443
|
+
format: ApiOutputFormat,
|
|
437
444
|
options: { verbose?: boolean } = {},
|
|
438
445
|
): string {
|
|
439
446
|
if (format === "json") {
|
|
440
447
|
return JSON.stringify(envelope, null, 2);
|
|
441
448
|
}
|
|
449
|
+
if (format === "ndjson") {
|
|
450
|
+
return JSON.stringify(envelope);
|
|
451
|
+
}
|
|
442
452
|
if (format === "yaml") {
|
|
443
453
|
return toYaml(envelope);
|
|
444
454
|
}
|
|
445
455
|
if (!envelope.ok) {
|
|
446
|
-
|
|
447
|
-
if (envelope.error.remediation) {
|
|
448
|
-
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
449
|
-
}
|
|
450
|
-
return lines.join("\n");
|
|
456
|
+
return formatCentrsErrorText(envelope.error, options);
|
|
451
457
|
}
|
|
452
458
|
const operation = envelope.meta.operation;
|
|
453
459
|
const { total, ok, failed } = envelope.data.summary;
|
|
@@ -468,10 +474,14 @@ export function renderApiFanoutEnvelope(
|
|
|
468
474
|
);
|
|
469
475
|
} else {
|
|
470
476
|
lines.push(
|
|
471
|
-
|
|
477
|
+
terminalSafeLine(
|
|
478
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
479
|
+
),
|
|
472
480
|
);
|
|
473
481
|
if (options.verbose && target.error.remediation) {
|
|
474
|
-
lines.push(
|
|
482
|
+
lines.push(
|
|
483
|
+
terminalSafeLine(` Fix: ${target.error.remediation}`),
|
|
484
|
+
);
|
|
475
485
|
}
|
|
476
486
|
}
|
|
477
487
|
}
|