@tikoci/centrs 0.1.3 → 0.1.4

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 (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/GLOSSARY.txt +213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +36 -8
  5. package/commands/AGENTS.md +7 -1
  6. package/commands/api/README.md +19 -18
  7. package/commands/api/examples.md +15 -0
  8. package/commands/btest/README.md +21 -32
  9. package/commands/check/README.md +6 -1
  10. package/commands/discover/README.md +5 -11
  11. package/commands/execute/README.md +41 -29
  12. package/commands/execute/examples.md +41 -3
  13. package/commands/explain/README.md +1716 -0
  14. package/commands/explain/examples.md +606 -0
  15. package/commands/retrieve/README.md +25 -27
  16. package/commands/retrieve/examples.md +16 -0
  17. package/commands/transfer/README.md +28 -20
  18. package/commands/transfer/examples.md +29 -0
  19. package/docs/CLI.md +353 -0
  20. package/docs/CONSTITUTION.md +66 -15
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -0
  23. package/docs/errors/quickchr/machine-not-found.md +22 -0
  24. package/docs/errors/quickchr/machine-stopped.md +20 -0
  25. package/docs/errors/quickchr/package-unavailable.md +27 -0
  26. package/docs/errors/quickchr/unsupported-via.md +35 -0
  27. package/docs/errors/quickchr/unsupported.md +35 -0
  28. package/docs/errors/routeros/version-unsupported.md +26 -0
  29. package/docs/errors/transport/provider-trust.md +29 -0
  30. package/docs/errors/usage/stdin-ignored.md +28 -0
  31. package/docs/errors/validation/unknown-attribute.md +37 -4
  32. package/package.json +45 -10
  33. package/src/api-fanout.ts +29 -14
  34. package/src/api.ts +97 -54
  35. package/src/cli/api.ts +25 -6
  36. package/src/cli/btest.ts +18 -2
  37. package/src/cli/execute.ts +25 -6
  38. package/src/cli/explain.ts +503 -0
  39. package/src/cli/retrieve.ts +33 -1
  40. package/src/cli/selection.ts +71 -11
  41. package/src/cli/settings.ts +2 -2
  42. package/src/cli/transfer.ts +23 -6
  43. package/src/cli.ts +15 -2
  44. package/src/core/envelope.ts +5 -2
  45. package/src/core/error-catalog.ts +40 -0
  46. package/src/core/preflight.ts +306 -0
  47. package/src/core/routeros-string.ts +24 -0
  48. package/src/errors.ts +1 -0
  49. package/src/execute-fanout.ts +29 -14
  50. package/src/execute.ts +114 -63
  51. package/src/explain/arg-tokens.ts +114 -0
  52. package/src/explain/args.ts +1403 -0
  53. package/src/explain/blocks.ts +132 -0
  54. package/src/explain/brace-slots.ts +143 -0
  55. package/src/explain/brace-tokens.ts +38 -0
  56. package/src/explain/catalog.ts +1345 -0
  57. package/src/explain/coordinates.ts +329 -0
  58. package/src/explain/defects.ts +217 -0
  59. package/src/explain/is-known-menu.ts +17 -0
  60. package/src/explain/menus.ts +664 -0
  61. package/src/explain/operator-tokens.ts +310 -0
  62. package/src/explain/operators.ts +458 -0
  63. package/src/explain/path-tokens.ts +150 -0
  64. package/src/explain/pathresolve.ts +1324 -0
  65. package/src/explain/quoted-string.ts +241 -0
  66. package/src/explain/scope-brace.ts +308 -0
  67. package/src/explain/segment.ts +757 -0
  68. package/src/explain/string-tokens.ts +67 -0
  69. package/src/explain/symbol-values.ts +520 -0
  70. package/src/explain/symbols.ts +1597 -0
  71. package/src/explain/token-ranges.ts +72 -0
  72. package/src/explain/transport.ts +497 -0
  73. package/src/explain/value-tokens.ts +80 -0
  74. package/src/explain/values.ts +357 -0
  75. package/src/explain/verbs.ts +64 -0
  76. package/src/explain/verbsplit.ts +813 -0
  77. package/src/explain/write.ts +815 -0
  78. package/src/explain.ts +2152 -0
  79. package/src/index.ts +206 -4
  80. package/src/protocols/mac-telnet-console.ts +61 -17
  81. package/src/resolver/facts.ts +1 -1
  82. package/src/resolver/index.ts +23 -4
  83. package/src/resolver/quickchr-provider.ts +597 -0
  84. package/src/resolver/selection.ts +99 -12
  85. package/src/resolver/service-endpoint.ts +155 -0
  86. package/src/resolver/target.ts +1 -1
  87. package/src/retrieve-fanout.ts +26 -12
  88. package/src/retrieve.ts +81 -18
  89. package/src/transfer-fanout.ts +42 -25
  90. package/src/transfer.ts +87 -44
@@ -65,6 +65,12 @@ export const selectionCommandOptions: readonly CliCommandOption[] = [
65
65
  flag: "--default",
66
66
  description: "Select the reserved `__default__` record.",
67
67
  },
68
+ {
69
+ flag: "--quickchr",
70
+ valueName: "<name>",
71
+ description:
72
+ "Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it).",
73
+ },
68
74
  {
69
75
  flag: "--concurrency",
70
76
  valueName: "<n>",
@@ -81,11 +87,13 @@ export interface SelectionFlags {
81
87
  bbox?: BboxPredicate;
82
88
  all: boolean;
83
89
  default: boolean;
90
+ /** Repeatable `--quickchr <name>` (named-live-provider; exclusive in v1). */
91
+ quickchr: string[];
84
92
  concurrency?: number;
85
93
  }
86
94
 
87
95
  export function emptySelectionFlags(): SelectionFlags {
88
- return { groups: [], where: [], all: false, default: false };
96
+ return { groups: [], where: [], all: false, default: false, quickchr: [] };
89
97
  }
90
98
 
91
99
  /** The selection flag tokens, for a command's `--help` table + unknown-flag suggestions. */
@@ -96,6 +104,7 @@ export const selectionFlagTokens = [
96
104
  "--bbox",
97
105
  "--all",
98
106
  "--default",
107
+ "--quickchr",
99
108
  "--concurrency",
100
109
  ] as const;
101
110
 
@@ -143,6 +152,9 @@ export function consumeSelectionFlag(
143
152
  case "--bbox":
144
153
  acc.bbox = parseBbox(expectValue(args, index + 1, arg));
145
154
  return index + 1;
155
+ case "--quickchr":
156
+ acc.quickchr.push(expectValue(args, index + 1, arg));
157
+ return index + 1;
146
158
  case "--concurrency": {
147
159
  const raw = expectValue(args, index + 1, arg);
148
160
  const parsed = parseStrictInteger(raw);
@@ -175,26 +187,74 @@ export function buildTargetSelection(
175
187
  where: flags.where,
176
188
  near: flags.near,
177
189
  bbox: flags.bbox,
190
+ quickchr: flags.quickchr,
178
191
  };
179
192
  }
180
193
 
194
+ /** True when any CDB selector flag is present (quickchr and positionals excluded). */
195
+ function hasCdbSelector(flags: SelectionFlags): boolean {
196
+ return (
197
+ flags.all ||
198
+ flags.default ||
199
+ flags.groups.length > 0 ||
200
+ flags.where.length > 0 ||
201
+ flags.near !== undefined ||
202
+ flags.bbox !== undefined
203
+ );
204
+ }
205
+
206
+ /**
207
+ * `--quickchr` exclusivity (v1, `docs/CONSTITUTION.md` → Resolution providers):
208
+ * a command targets quickchr machines OR CDB/literal targets, never both.
209
+ * Commands call this after splitting positionals, in both single-target and
210
+ * fan-out mode; relaxing it later is additive.
211
+ */
212
+ export function assertQuickchrExclusive(
213
+ flags: SelectionFlags,
214
+ targetPositionalCount: number,
215
+ ): void {
216
+ if (flags.quickchr.length === 0) {
217
+ return;
218
+ }
219
+ if (hasCdbSelector(flags) || targetPositionalCount > 0) {
220
+ throw new CentrsError({
221
+ code: "usage/conflicting-flags",
222
+ summary:
223
+ "`--quickchr` is exclusive: a command targets quickchr machines or CDB/literal targets, not both.",
224
+ remediation:
225
+ "Drop the positional target / CDB selector (or the `--quickchr` flag) so the selection has one target source.",
226
+ context: {
227
+ quickchr: [...flags.quickchr],
228
+ positionals: targetPositionalCount,
229
+ },
230
+ });
231
+ }
232
+ }
233
+
181
234
  /**
182
- * Fan-out mode is keyed on INTENT, not resolved count: any selector flag, or more
183
- * than one positional target. A plain single-positional call stays single-target
184
- * (never `FanoutData`); a selector that resolves to one/zero members is still
185
- * fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
235
+ * True when any target-taking flag is present, so positionals are operation
236
+ * args, not targets. Superset of {@link isFanoutMode}'s flag term: a single
237
+ * `--quickchr` names the target (like a lone positional, still single-target
238
+ * mode) but already claims the positional slots.
239
+ */
240
+ export function hasTargetSelector(flags: SelectionFlags): boolean {
241
+ return hasCdbSelector(flags) || flags.quickchr.length > 0;
242
+ }
243
+
244
+ /**
245
+ * Fan-out mode is keyed on INTENT, not resolved count: any selector flag, more
246
+ * than one positional target, or a repeated `--quickchr`. A plain
247
+ * single-positional call — and a single `--quickchr <name>` — stays
248
+ * single-target (never `FanoutData`); a selector that resolves to one/zero
249
+ * members is still fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
186
250
  */
187
251
  export function isFanoutMode(
188
252
  flags: SelectionFlags,
189
253
  targetPositionalCount: number,
190
254
  ): boolean {
191
255
  return (
192
- flags.all ||
193
- flags.default ||
194
- flags.groups.length > 0 ||
195
- flags.where.length > 0 ||
196
- flags.near !== undefined ||
197
- flags.bbox !== undefined ||
256
+ hasCdbSelector(flags) ||
257
+ flags.quickchr.length > 1 ||
198
258
  targetPositionalCount > 1
199
259
  );
200
260
  }
@@ -36,7 +36,7 @@ export const settingsCommand: CliCommandMetadata = {
36
36
  name: "settings",
37
37
  usage: "centrs settings [print|get|set|reset] [args] [flags]",
38
38
  summary:
39
- "Manage centrs's own global preferences (centrs.env) and inspect the __default__ CDB record.",
39
+ "Manage centrs's own global preferences (`centrs.env`) and inspect the `__default__` CDB record.",
40
40
  options: [
41
41
  {
42
42
  flag: "--all",
@@ -47,7 +47,7 @@ export const settingsCommand: CliCommandMetadata = {
47
47
  flag: "--cdb-file",
48
48
  valueName: "<path>",
49
49
  description:
50
- "`print` only — CDB to probe for the __default__ record summary (falls back to normal CDB resolution; never centrs.env).",
50
+ "`print` only — CDB to probe for the `__default__` record summary (falls back to normal CDB resolution; never centrs.env).",
51
51
  },
52
52
  {
53
53
  flag: "--cdb-password",
@@ -23,6 +23,7 @@ import {
23
23
  transferOutputFormats,
24
24
  transferVerbs,
25
25
  } from "../index.ts";
26
+ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
26
27
  import {
27
28
  type CliCommandMetadata,
28
29
  expectValue,
@@ -38,6 +39,7 @@ import {
38
39
  withTips,
39
40
  } from "./missing-target.ts";
40
41
  import {
42
+ assertQuickchrExclusive,
41
43
  buildTargetSelection,
42
44
  consumeSelectionFlag,
43
45
  emptySelectionFlags,
@@ -82,8 +84,7 @@ export const transferCommand: CliCommandMetadata = {
82
84
  "Accept a self-signed TLS cert (https/api-ssl) or a new SSH host key. Default verifies.",
83
85
  },
84
86
  {
85
- flag: "--force",
86
- valueName: "(--overwrite)",
87
+ flag: "--force / --overwrite",
87
88
  description: "Replace an existing destination. Default refuses it.",
88
89
  },
89
90
  ...selectionCommandOptions,
@@ -99,10 +100,10 @@ export const transferCommand: CliCommandMetadata = {
99
100
  "Confirm a mutating fan-out (upload/remove/mkdir/copy) across multiple routers in non-interactive runs.",
100
101
  },
101
102
  {
102
- flag: "--verify",
103
+ flag: "--verify / --no-verify",
103
104
  valueName: "<size|checksum|off>",
104
105
  description:
105
- "Post-transfer integrity check. Default size; --no-verify skips.",
106
+ "Post-transfer integrity check. Default size; `--no-verify` skips.",
106
107
  },
107
108
  {
108
109
  flag: "--type",
@@ -147,6 +148,7 @@ export const transferCommand: CliCommandMetadata = {
147
148
  "Output format. Defaults to text; --json / --yaml shortcuts.",
148
149
  },
149
150
  { flag: "--json", description: "Shortcut for --format json." },
151
+ { flag: "--yaml", description: "Shortcut for --format yaml." },
150
152
  {
151
153
  flag: "--cdb-file",
152
154
  valueName: "<path>",
@@ -479,7 +481,10 @@ function assemblePositionals(
479
481
  selectionFlags: SelectionFlags,
480
482
  fixedVerb?: TransferVerb,
481
483
  ): { request: TransferRequest; targetPositionals: readonly string[] } {
482
- const hasSelector = isFanoutMode(selectionFlags, 0);
484
+ // Any target-taking flag (CDB selector or `--quickchr`) claims the target
485
+ // slots, so every positional belongs to the verb/paths.
486
+ const hasSelector =
487
+ isFanoutMode(selectionFlags, 0) || selectionFlags.quickchr.length > 0;
483
488
  let verb: TransferVerb;
484
489
  let targetPositionals: readonly string[];
485
490
  let rest: readonly string[];
@@ -522,9 +527,21 @@ function assemblePositionals(
522
527
 
523
528
  const request: TransferRequest = { verb, ...flags };
524
529
  applyVerbPaths(request, verb, rest);
530
+ assertQuickchrExclusive(selectionFlags, targetPositionals.length);
531
+ // Direct connection overrides conflict with `--quickchr` globally: reject at
532
+ // parse time so a repeated `--quickchr` gets one usage error (exit 1), never
533
+ // per-member failures. The resolver re-checks for library callers.
534
+ if (selectionFlags.quickchr.length > 0) {
535
+ assertNoQuickchrOverrideConflict(request, selectionFlags.quickchr[0] ?? "");
536
+ }
525
537
 
526
- // Single-target (not fan-out mode) needs exactly one resolved target.
538
+ // Single-target (not fan-out mode) needs exactly one resolved target: one
539
+ // positional, or one `--quickchr <name>` (the named-live-provider).
527
540
  if (!isFanoutMode(selectionFlags, targetPositionals.length)) {
541
+ if (selectionFlags.quickchr.length === 1) {
542
+ request.quickchr = selectionFlags.quickchr[0];
543
+ return { request, targetPositionals };
544
+ }
528
545
  const targetInput = targetPositionals[0];
529
546
  if (!targetInput) {
530
547
  throw missingTargetError({
package/src/cli.ts CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { apiCommand, runApiCli } from "./cli/api.ts";
4
4
  import { btestCommand, runBtestCli } from "./cli/btest.ts";
5
+ import type { CliCommandMetadata } from "./cli/common.ts";
5
6
  import { devicesCommand, runDevicesCli } from "./cli/devices.ts";
6
7
  import { discoverCommand, runDiscoverCli } from "./cli/discover.ts";
7
8
  import { executeCommand, runExecuteCli } from "./cli/execute.ts";
9
+ import { explainCliCommand, runExplainCli } from "./cli/explain.ts";
8
10
  import { mcpCommand, runMcpCli } from "./cli/mcp.ts";
9
11
  import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
10
12
  import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
@@ -13,18 +15,26 @@ import { runTransferCli, transferCommand } from "./cli/transfer.ts";
13
15
  import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
14
16
  import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
15
17
 
16
- const commandSummaries: ReadonlyArray<{ name: string; summary: string }> = [
18
+ /** Every CLI command's metadata, in help/docs order. `scripts/gen-cli-docs.ts` renders `docs/CLI.md` from this. */
19
+ export const cliCommands: ReadonlyArray<CliCommandMetadata> = [
17
20
  retrieveCommand,
18
21
  executeCommand,
19
22
  apiCommand,
20
23
  transferCommand,
21
24
  terminalCommand,
25
+ explainCliCommand,
22
26
  devicesCommand,
23
27
  discoverCommand,
24
28
  btestCommand,
25
29
  mcpCommand,
26
30
  settingsCommand,
27
- ].map((command) => ({ name: command.name, summary: command.summary }));
31
+ ];
32
+
33
+ const commandSummaries: ReadonlyArray<{ name: string; summary: string }> =
34
+ cliCommands.map((command) => ({
35
+ name: command.name,
36
+ summary: command.summary,
37
+ }));
28
38
 
29
39
  export function renderCliHelp(): string {
30
40
  return [
@@ -75,6 +85,9 @@ export async function runCli(
75
85
  if (command === "download") {
76
86
  return await runTransferCli(rest, { fixedVerb: "download" });
77
87
  }
88
+ if (command === "explain") {
89
+ return await runExplainCli(rest);
90
+ }
78
91
  if (command === "devices") {
79
92
  return await runDevicesCli(rest);
80
93
  }
@@ -27,7 +27,9 @@ import type { RouterOsProtocol } from "../protocols/index.ts";
27
27
  /**
28
28
  * Where a resolved setting/identity field came from. Precedence (low → high)
29
29
  * is config < comment-kv < env < cli; `cdb` and `arp` describe identity
30
- * resolution rather than settings precedence.
30
+ * resolution rather than settings precedence, and `provider` marks a field a
31
+ * named-live-provider supplied (e.g. `--quickchr`), which bypasses the ladder
32
+ * entirely (`docs/CONSTITUTION.md` → Resolution providers).
31
33
  */
32
34
  export type SettingSourceKind =
33
35
  | "default"
@@ -36,7 +38,8 @@ export type SettingSourceKind =
36
38
  | "env"
37
39
  | "cli"
38
40
  | "cdb"
39
- | "arp";
41
+ | "arp"
42
+ | "provider";
40
43
 
41
44
  export interface SettingSource {
42
45
  kind: SettingSourceKind;
@@ -234,6 +234,31 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
234
234
  summary: "A received MNDP datagram could not be decoded.",
235
235
  },
236
236
 
237
+ // quickchr/*
238
+ {
239
+ code: "quickchr/package-unavailable",
240
+ summary: "The optional `@tikoci/quickchr` package is not installed.",
241
+ },
242
+ {
243
+ code: "quickchr/unsupported",
244
+ summary:
245
+ "The installed `@tikoci/quickchr` is too old or speaks a newer descriptor version than centrs understands.",
246
+ },
247
+ {
248
+ code: "quickchr/machine-not-found",
249
+ summary: "No quickchr machine is registered under that name.",
250
+ },
251
+ {
252
+ code: "quickchr/machine-stopped",
253
+ summary:
254
+ "The quickchr machine is not running, so it has no live connection facts.",
255
+ },
256
+ {
257
+ code: "quickchr/unsupported-via",
258
+ summary:
259
+ "The chosen `--via` has no usable service on the quickchr machine (not forwarded, unavailable, or SSH without batch-capable auth).",
260
+ },
261
+
237
262
  // routeros/*
238
263
  {
239
264
  code: "routeros/api-fatal",
@@ -323,6 +348,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
323
348
  code: "routeros/unsupported-capability",
324
349
  summary: "The chosen protocol does not support this command capability.",
325
350
  },
351
+ {
352
+ code: "routeros/version-unsupported",
353
+ summary:
354
+ "The device's RouterOS version does not provide the operation this command needs.",
355
+ },
326
356
 
327
357
  // settings/*
328
358
  {
@@ -435,6 +465,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
435
465
  summary:
436
466
  "Peer verification was disabled via --insecure (TLS / SSH host-key checks skipped).",
437
467
  },
468
+ {
469
+ code: "transport/provider-trust",
470
+ summary:
471
+ "Peer verification is relaxed for a named-live-provider endpoint (a quickchr VM's self-signed TLS cert / ephemeral SSH host key), trusted by the provider's loopback-forward voucher.",
472
+ },
438
473
  {
439
474
  code: "transport/local-tool-missing",
440
475
  summary:
@@ -475,6 +510,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
475
510
  code: "usage/invalid-concurrency",
476
511
  summary: "The --concurrency value must be an integer >= 1.",
477
512
  },
513
+ {
514
+ code: "usage/stdin-ignored",
515
+ summary:
516
+ "stdin was redirected but the input came from the positional argument, so anything piped in was not analyzed.",
517
+ },
478
518
  {
479
519
  code: "usage/fanout-not-supported",
480
520
  summary:
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Capability/version pre-flight for centrs-generated RouterOS commands (#129).
3
+ *
4
+ * centrs sometimes generates a command that assumes a device capability the
5
+ * device may lack (e.g. `transfer mkdir` → `/file add type=directory`, absent
6
+ * on older RouterOS). Without a pre-flight that surfaces as a raw protocol
7
+ * error; with one it becomes a typed `routeros/version-unsupported` error
8
+ * naming the versions that do support the operation. (Distinct from
9
+ * `routeros/unsupported-capability`, which is a *protocol*-side gap — the
10
+ * transport cannot carry the operation at all.)
11
+ *
12
+ * Design:
13
+ * - A {@link CapabilityRequirement} carries the supported
14
+ * {@link RouterOsVersionRange} list as **data**. Ranges are inclusive-`min` /
15
+ * exclusive-`maxExclusive`, so a backported feature is a list of per-release-
16
+ * line minimums (e.g. `[{min:"7.21.4", maxExclusive:"7.22"}, {min:"7.22.2"}]`).
17
+ * The grounding for each requirement (CHR run, rosetta changelog/command-diff)
18
+ * lives at the requirement's definition site — this module only evaluates.
19
+ * - The device version comes from a {@link DeviceVersion}: a live read
20
+ * ({@link probeDeviceVersion}), the CDB record's stored `version=` comment
21
+ * fact ({@link versionFactFromComment} — stale-but-cheap, written by
22
+ * `discover --save`/`devices --check`), or the caller.
23
+ * - An **unknown** version never blocks (crash-early: the device's own error
24
+ * still surfaces through the RouterOS error mapping); only a version known to
25
+ * be outside every supported range fails, via {@link assertCapability}.
26
+ *
27
+ * Version ordering is suffix-aware: `7.23beta1 < 7.23beta2 < 7.23rc1 < 7.23 <
28
+ * 7.23.1` (RouterOS version format `MAJOR.MINOR[.PATCH][beta|rc N]`). The
29
+ * comparator tolerates trailing channel/date noise (`"7.21.4 (long-term) …"`),
30
+ * matching what `/system/resource` reports. Promoted from the integration
31
+ * harness's `routerOsAtLeast` (`test/integration/chr.ts`), which now delegates
32
+ * here.
33
+ */
34
+
35
+ import { CentrsError } from "../errors.ts";
36
+ import type { ProtocolAdapter } from "../protocols/adapter.ts";
37
+ import { parseRawCommentFacts } from "../resolver/comment-kv.ts";
38
+
39
+ /** Parsed RouterOS version, ordered by the tuple (major, minor, patch, stage, stageIteration). */
40
+ export interface RouterOsVersionParts {
41
+ major: number;
42
+ minor: number;
43
+ patch: number;
44
+ /** Prerelease stage rank: beta=0 < rc=1 < release=2. */
45
+ stageRank: 0 | 1 | 2;
46
+ /** Iteration within the stage (`beta2` → 2); 0 for a release. */
47
+ stageIteration: number;
48
+ }
49
+
50
+ /**
51
+ * Parse a RouterOS version out of a possibly-noisy string
52
+ * (`"7.21.4 (long-term) 2026-04-21"` → 7.21.4). Returns `undefined` when no
53
+ * `major.minor` shape is present.
54
+ */
55
+ export function parseRouterOsVersion(
56
+ raw: string,
57
+ ): RouterOsVersionParts | undefined {
58
+ // Bounded digit runs ({1,4}) keep this linear on adversarial input — an
59
+ // unbounded `\d+` here is a polynomial-ReDoS vector on strings of many
60
+ // repeated digits (CodeQL js/polynomial-redos). RouterOS version numbers
61
+ // never approach four digits per field, so the bound is free.
62
+ //
63
+ // The `(?<![\d.])` / `(?!\d)` token boundaries keep the (unanchored) match
64
+ // from latching onto a *fragment* of a larger number: without them
65
+ // `"12345.67"` matched `"2345.67"` and `"127.0.0.1"` matched `"127.0"`.
66
+ // They still allow legitimately noisy strings ("7.23.2 (stable)", "v7.23").
67
+ const match = raw.match(
68
+ /(?<![\d.])(\d{1,4})\.(\d{1,4})(?:\.(\d{1,4}))?(?:(beta|rc)(\d{1,4}))?(?![\d.])/i,
69
+ );
70
+ if (!match) {
71
+ return undefined;
72
+ }
73
+ const stage = match[4]?.toLowerCase();
74
+ return {
75
+ major: Number(match[1]),
76
+ minor: Number(match[2]),
77
+ patch: Number(match[3] ?? 0),
78
+ stageRank: stage === undefined ? 2 : stage === "rc" ? 1 : 0,
79
+ stageIteration: Number(match[5] ?? 0),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Compare two RouterOS version strings: negative when `a < b`, 0 when equal,
85
+ * positive when `a > b`. An unparseable version sorts below every parseable
86
+ * one (and equal to another unparseable one).
87
+ */
88
+ export function compareRouterOsVersion(a: string, b: string): number {
89
+ const left = parseRouterOsVersion(a);
90
+ const right = parseRouterOsVersion(b);
91
+ if (left === undefined || right === undefined) {
92
+ return (left === undefined ? 0 : 1) - (right === undefined ? 0 : 1);
93
+ }
94
+ return (
95
+ left.major - right.major ||
96
+ left.minor - right.minor ||
97
+ left.patch - right.patch ||
98
+ left.stageRank - right.stageRank ||
99
+ left.stageIteration - right.stageIteration
100
+ );
101
+ }
102
+
103
+ /** True when `running` ≥ `target`. Unparseable `running` is never ≥ a parseable target. */
104
+ export function routerOsVersionAtLeast(
105
+ running: string,
106
+ target: string,
107
+ ): boolean {
108
+ if (
109
+ parseRouterOsVersion(running) === undefined &&
110
+ parseRouterOsVersion(target) !== undefined
111
+ ) {
112
+ return false;
113
+ }
114
+ return compareRouterOsVersion(running, target) >= 0;
115
+ }
116
+
117
+ /**
118
+ * One contiguous supported window. `min` is inclusive; `maxExclusive` bounds a
119
+ * backport window (omit it for "this version and everything after").
120
+ */
121
+ export interface RouterOsVersionRange {
122
+ min: string;
123
+ maxExclusive?: string;
124
+ }
125
+
126
+ /**
127
+ * True when `version` falls inside `range`. A malformed range is a definition
128
+ * bug in a {@link CapabilityRequirement}, not a device condition, so it throws
129
+ * rather than failing open (an unparseable `min` would otherwise sort below
130
+ * every real version and mark everything supported).
131
+ */
132
+ export function versionInRange(
133
+ version: string,
134
+ range: RouterOsVersionRange,
135
+ ): boolean {
136
+ if (parseRouterOsVersion(range.min) === undefined) {
137
+ throw new TypeError(
138
+ `Invalid capability range min ${JSON.stringify(range.min)}; ` +
139
+ "set CapabilityRequirement.supported[].min to a RouterOS major.minor[.patch] version.",
140
+ );
141
+ }
142
+ if (
143
+ range.maxExclusive !== undefined &&
144
+ (parseRouterOsVersion(range.maxExclusive) === undefined ||
145
+ compareRouterOsVersion(range.min, range.maxExclusive) >= 0)
146
+ ) {
147
+ throw new TypeError(
148
+ `Invalid capability range maxExclusive ${JSON.stringify(range.maxExclusive)}; ` +
149
+ "set CapabilityRequirement.supported[].maxExclusive to a RouterOS version greater than min.",
150
+ );
151
+ }
152
+ if (!routerOsVersionAtLeast(version, range.min)) {
153
+ return false;
154
+ }
155
+ return (
156
+ range.maxExclusive === undefined ||
157
+ compareRouterOsVersion(version, range.maxExclusive) < 0
158
+ );
159
+ }
160
+
161
+ /**
162
+ * A capability a centrs-generated command depends on, with the version windows
163
+ * where RouterOS provides it. Define these next to the consumer (e.g. the
164
+ * transfer spec owns the `mkdir` requirement) and document the grounding
165
+ * evidence there.
166
+ */
167
+ export interface CapabilityRequirement {
168
+ /** Stable slug for error context, e.g. `file-add-directory`. */
169
+ capability: string;
170
+ /** Human phrase for the operation, e.g. "create directories with `/file add type=directory`". */
171
+ summary: string;
172
+ /** Windows where the capability exists; supported when ANY range matches. */
173
+ supported: readonly RouterOsVersionRange[];
174
+ /** Extra guidance appended to the typed error's remediation. */
175
+ remediation?: string;
176
+ }
177
+
178
+ /** Where a pre-flighted device version came from (error context + envelope provenance). */
179
+ export type DeviceVersionSource = "live" | "cdb-fact" | "caller";
180
+
181
+ /** A device version paired with its provenance. */
182
+ export interface DeviceVersion {
183
+ version: string;
184
+ source: DeviceVersionSource;
185
+ }
186
+
187
+ export type PreflightState = "supported" | "unsupported" | "unknown";
188
+
189
+ /** Outcome of a capability pre-flight. */
190
+ export interface PreflightVerdict {
191
+ state: PreflightState;
192
+ /** Echo of the checked device version, when one was available. */
193
+ device?: DeviceVersion;
194
+ }
195
+
196
+ /**
197
+ * Evaluate a requirement against a device version. `unknown` when no version
198
+ * is available or it does not parse — callers proceed on `unknown` (the device
199
+ * still reports its own error, mapped by the RouterOS error vocabulary).
200
+ */
201
+ export function checkCapability(
202
+ requirement: CapabilityRequirement,
203
+ device?: DeviceVersion,
204
+ ): PreflightVerdict {
205
+ if (
206
+ device === undefined ||
207
+ parseRouterOsVersion(device.version) === undefined
208
+ ) {
209
+ return { state: "unknown" };
210
+ }
211
+ const supported = requirement.supported.some((range) =>
212
+ versionInRange(device.version, range),
213
+ );
214
+ return { state: supported ? "supported" : "unsupported", device };
215
+ }
216
+
217
+ /** Render supported windows for humans: `7.21.4–7.22 (excl.), 7.22.2+`. */
218
+ export function renderSupportedRanges(
219
+ ranges: readonly RouterOsVersionRange[],
220
+ ): string {
221
+ return ranges
222
+ .map((range) =>
223
+ range.maxExclusive === undefined
224
+ ? `${range.min}+`
225
+ : `${range.min}–${range.maxExclusive} (excl.)`,
226
+ )
227
+ .join(", ");
228
+ }
229
+
230
+ /**
231
+ * Pre-flight gate: throw a typed `routeros/version-unsupported` error when
232
+ * the device version is known and outside every supported window; otherwise
233
+ * return the verdict (`unknown` passes — see {@link checkCapability}).
234
+ */
235
+ export function assertCapability(
236
+ requirement: CapabilityRequirement,
237
+ device?: DeviceVersion,
238
+ ): PreflightVerdict {
239
+ const verdict = checkCapability(requirement, device);
240
+ if (verdict.state !== "unsupported" || verdict.device === undefined) {
241
+ return verdict;
242
+ }
243
+ const supported = renderSupportedRanges(requirement.supported);
244
+ throw new CentrsError({
245
+ code: "routeros/version-unsupported",
246
+ summary: `RouterOS ${verdict.device.version} cannot ${requirement.summary}; it needs ${supported}.`,
247
+ remediation:
248
+ (requirement.remediation ? `${requirement.remediation} ` : "") +
249
+ (verdict.device.source === "cdb-fact"
250
+ ? "The version came from the stored CDB `version=` fact and may be stale; upgrade RouterOS, or refresh the fact if the device was already upgraded."
251
+ : "Upgrade RouterOS to a supported version."),
252
+ context: {
253
+ capability: requirement.capability,
254
+ version: verdict.device.version,
255
+ versionSource: verdict.device.source,
256
+ supported: requirement.supported.map((range) => ({ ...range })),
257
+ },
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Read a stored device version from a CDB record's comment kv-soup (`version=`
263
+ * — the derived fact `discover --save` and `devices --check` write). Stale by
264
+ * nature; provenance is `cdb-fact`.
265
+ */
266
+ export function versionFactFromComment(
267
+ comment: string,
268
+ ): DeviceVersion | undefined {
269
+ const version = parseRawCommentFacts(comment)["version"];
270
+ if (version === undefined || parseRouterOsVersion(version) === undefined) {
271
+ return undefined;
272
+ }
273
+ return { version, source: "cdb-fact" };
274
+ }
275
+
276
+ /**
277
+ * Live device-version probe over an already-built adapter. Structured
278
+ * transports read `/system/resource`; console transports (ssh/mac-telnet) run
279
+ * `:put [/system/resource/ get version]`. Transport/auth errors propagate —
280
+ * only a well-formed reply that simply lacks a version yields `undefined`.
281
+ */
282
+ export async function probeDeviceVersion(
283
+ adapter: ProtocolAdapter,
284
+ ): Promise<DeviceVersion | undefined> {
285
+ if (adapter.capabilities.retrieve) {
286
+ const resource = await adapter.getSingleton("/system/resource");
287
+ const version =
288
+ typeof resource === "object" && resource !== null
289
+ ? (resource as Record<string, unknown>)["version"]
290
+ : undefined;
291
+ if (typeof version === "string" && parseRouterOsVersion(version)) {
292
+ return { version, source: "live" };
293
+ }
294
+ return undefined;
295
+ }
296
+ const result = await adapter.execute({
297
+ path: "/system/resource",
298
+ command: "print",
299
+ script: ":put [/system/resource/ get version]",
300
+ });
301
+ const version = result.ret?.trim();
302
+ if (version && parseRouterOsVersion(version)) {
303
+ return { version, source: "live" };
304
+ }
305
+ return undefined;
306
+ }