@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.
- package/CHANGELOG.md +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
package/src/core/fanout.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|