@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.
- package/CHANGELOG.md +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -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/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- 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 +206 -4
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- package/src/transfer.ts +87 -44
package/src/cli/selection.ts
CHANGED
|
@@ -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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
|
193
|
-
flags.
|
|
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
|
}
|
package/src/cli/settings.ts
CHANGED
|
@@ -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",
|
package/src/cli/transfer.ts
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
}
|
package/src/core/envelope.ts
CHANGED
|
@@ -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
|
+
}
|