@tikoci/centrs 0.1.2 → 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 (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
@@ -11,9 +11,18 @@
11
11
  * and counting.
12
12
  */
13
13
 
14
- import { CentrsError } from "../errors.ts";
14
+ import { CentrsError, serializeCentrsError } from "../errors.ts";
15
15
  import type { RouterOsProtocol } from "../protocols/index.ts";
16
- import type { FanoutSummary } from "./envelope.ts";
16
+ import type {
17
+ CentrsEnvelope,
18
+ CentrsErrorEnvelope,
19
+ CentrsSuccessEnvelope,
20
+ CommonSettingsMeta,
21
+ EnvelopeTargetMeta,
22
+ FanoutData,
23
+ FanoutSummary,
24
+ Warning,
25
+ } from "./envelope.ts";
17
26
 
18
27
  /** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
19
28
  export const FANOUT_CONCURRENCY_DEFAULTS: Record<
@@ -158,3 +167,135 @@ export function fanoutBackoffMs(attempt: number): number {
158
167
  export function defaultFanoutSleep(ms: number): Promise<void> {
159
168
  return new Promise((resolve) => setTimeout(resolve, ms));
160
169
  }
170
+
171
+ /**
172
+ * The per-command fan-out loop, generic over the selection member, the resolved
173
+ * per-target plan, and the inner envelope type. Mirrors the original
174
+ * `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
175
+ * an inner error envelope, never a throw out of the batch); run the resolved
176
+ * request through {@link runWithRetry} (a retryable transient drop is retried,
177
+ * everything else recovers to an inner error envelope). Results are returned in
178
+ * input (record-index) order via {@link runBoundedPool}.
179
+ */
180
+ export async function runFanout<Member, Resolved, Env>(opts: {
181
+ members: readonly Member[];
182
+ concurrency: number;
183
+ resolve: (member: Member) => Promise<Resolved> | Resolved;
184
+ onResolveError: (member: Member, error: unknown) => Env;
185
+ execute: (resolved: Resolved, member: Member) => Promise<Env>;
186
+ onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
187
+ sleep?: (ms: number) => Promise<void>;
188
+ }): Promise<Env[]> {
189
+ return runBoundedPool(opts.members, opts.concurrency, async (member) => {
190
+ let resolved: Resolved;
191
+ try {
192
+ resolved = await opts.resolve(member);
193
+ } catch (error) {
194
+ return opts.onResolveError(member, error);
195
+ }
196
+ return runWithRetry<Env>(
197
+ () => opts.execute(resolved, member),
198
+ (error) => opts.onExecuteError(resolved, member, error),
199
+ { sleep: opts.sleep },
200
+ );
201
+ });
202
+ }
203
+
204
+ /**
205
+ * The uniform fan-out process exit code (granular 0/2/1; see
206
+ * `docs/CONSTITUTION.md`, Target selection). `0` = every target ok (or an empty
207
+ * selection); `2` = partial (some ok, some failed); `1` = orchestration error
208
+ * (outer `ok: false`) OR every target failed. Decoupled from the envelope's
209
+ * outer `ok`, which only reports orchestration success.
210
+ */
211
+ export function fanoutExitCode(envelope: {
212
+ ok: boolean;
213
+ data?: { summary?: FanoutSummary };
214
+ }): 0 | 1 | 2 {
215
+ if (!envelope.ok) {
216
+ return 1;
217
+ }
218
+ const summary = envelope.data?.summary;
219
+ if (!summary || summary.total === 0 || summary.failed === 0) {
220
+ return 0;
221
+ }
222
+ if (summary.ok === 0) {
223
+ return 1;
224
+ }
225
+ return 2;
226
+ }
227
+
228
+ /** Collapse per-target `via` to one value, or `null` when targets disagree. */
229
+ export function commonVia(
230
+ targets: readonly { meta: { via: RouterOsProtocol | null } }[],
231
+ ): RouterOsProtocol | null {
232
+ let via: RouterOsProtocol | null | undefined;
233
+ for (const target of targets) {
234
+ const candidate = target.meta.via;
235
+ if (via === undefined) {
236
+ via = candidate;
237
+ } else if (via !== candidate) {
238
+ return null;
239
+ }
240
+ }
241
+ return via ?? null;
242
+ }
243
+
244
+ /**
245
+ * Assemble the locked fan-out success envelope: `data = { summary, targets }`,
246
+ * outer `ok: true` = orchestration succeeded. The command owns its outer
247
+ * `operation` meta shape and passes it in.
248
+ */
249
+ export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
250
+ summary: FanoutSummary;
251
+ targets: readonly CentrsEnvelope<Data, Op>[];
252
+ warnings: readonly Warning[];
253
+ settings: CommonSettingsMeta;
254
+ via: RouterOsProtocol | null;
255
+ operation: OuterOp;
256
+ }): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
257
+ return {
258
+ ok: true,
259
+ data: { summary: input.summary, targets: input.targets },
260
+ warnings: input.warnings,
261
+ tips: [],
262
+ meta: {
263
+ target: {},
264
+ via: input.via,
265
+ settings: input.settings,
266
+ operation: input.operation,
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Build an INNER per-target error envelope for a member that failed to resolve
273
+ * (before any transport attempt). Carries the member's target meta so the
274
+ * fan-out output identifies which device failed.
275
+ */
276
+ export function buildFanoutResolveFailure<Op = unknown>(input: {
277
+ error: unknown;
278
+ target: EnvelopeTargetMeta;
279
+ warnings?: readonly Warning[];
280
+ summary?: string;
281
+ }): CentrsErrorEnvelope<Op> {
282
+ const centrsError =
283
+ input.error instanceof CentrsError
284
+ ? input.error
285
+ : new CentrsError({
286
+ code: "internal/unhandled",
287
+ summary: input.summary ?? "Failed to resolve a fanout target.",
288
+ cause: input.error,
289
+ });
290
+ return {
291
+ ok: false,
292
+ error: serializeCentrsError(centrsError),
293
+ warnings: input.warnings ?? [],
294
+ tips: [],
295
+ meta: {
296
+ target: input.target,
297
+ via: null,
298
+ settings: {},
299
+ },
300
+ };
301
+ }
@@ -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
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Quote RouterOS command text as one double-quoted string literal.
3
+ *
4
+ * `$` is load-bearing here: RouterOS expands variables inside double quotes,
5
+ * so leaving it bare changes the bytes seen by wrappers such as
6
+ * `:put [:parse "<input>"]` before the parser receives them.
7
+ * Control whitespace (`\t`/`\r`/`\n`) is escaped so the decoded string
8
+ * carries the caller's original bytes. This is defensive: RouterOS 7.20
9
+ * changelog says "replace TAB characters with spaces when editing scripts
10
+ * and added `tab-width` in `/console/settings`" — version-gated (absent
11
+ * before 7.20), width-configurable via `tab-width`, and scoped to the
12
+ * script editor, not grounded as string-literal expansion (see #276).
13
+ * Escaping preserves the caller's bytes whether the device expands or
14
+ * preserves a raw tab.
15
+ */
16
+ export function routerOsStringLiteral(value: string): string {
17
+ return `"${value
18
+ .replaceAll("\\", "\\\\")
19
+ .replaceAll('"', '\\"')
20
+ .replaceAll("$", "\\$")
21
+ .replaceAll("\t", "\\t")
22
+ .replaceAll("\r", "\\r")
23
+ .replaceAll("\n", "\\n")}"`;
24
+ }