@tikoci/centrs 0.1.6 → 0.1.8-next.0

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 (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  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 +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -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 +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. package/src/transfer.ts +234 -32
@@ -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
  }