@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. 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
- See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
8
- contract. This stub will be expanded with the typical trigger and remediation
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.7",
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.7",
16
- "@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
17
- "@types/bun": "^1.3.13",
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.0.0",
19
+ "cspell": "^10.3.6",
20
20
  "has-flag": "4.0.0",
21
- "markdownlint-cli2": "^0.23.0",
22
- "secretlint": "^13.0.2",
23
- "typedoc": "^0.28.19"
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.7"
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.29.0",
140
- "zod": "^4.4.3"
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
- * - `--listen`/`--stream` and `--raw` are rejected in fan-out mode by the CLI
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 { CentrsError, serializeCentrsError } from "./errors.ts";
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 per target in record-index order.
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: "json" | "yaml" | "text",
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
- const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
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
- ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
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(` Fix: ${target.error.remediation}`);
482
+ lines.push(
483
+ terminalSafeLine(` Fix: ${target.error.remediation}`),
484
+ );
475
485
  }
476
486
  }
477
487
  }