@tikoci/centrs 0.1.3 → 0.1.6
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 +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- 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 +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- 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/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- 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/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- 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 +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- package/src/transfer.ts +88 -45
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/terminal.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `process.*`); the core relay in `../terminal.ts` stays io-agnostic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { toYaml } from "../core/yaml.ts";
|
|
9
10
|
import {
|
|
10
11
|
asCentrsError,
|
|
11
12
|
CentrsError,
|
|
@@ -18,7 +19,6 @@ import {
|
|
|
18
19
|
type TerminalIo,
|
|
19
20
|
type TerminalRequest,
|
|
20
21
|
} from "../index.ts";
|
|
21
|
-
import { toYaml } from "../retrieve.ts";
|
|
22
22
|
import {
|
|
23
23
|
type CliCommandMetadata,
|
|
24
24
|
expectValue,
|
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;
|
|
@@ -108,6 +111,50 @@ export interface EnvelopeTargetMeta {
|
|
|
108
111
|
sources?: Record<string, SettingSource>;
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
/**
|
|
115
|
+
* One stage of the two-stage validation gate (GH#354).
|
|
116
|
+
*
|
|
117
|
+
* `offline` is the corpus-gated analyzer (`src/offline-gate.ts`): it opens no
|
|
118
|
+
* connection, so a `failed` offline stage means the device was never contacted.
|
|
119
|
+
* `device` is the RouterOS-side preflight — `:put [:parse …]` and/or
|
|
120
|
+
* `/console/inspect request=child`, depending on the command and transport.
|
|
121
|
+
*
|
|
122
|
+
* Stages appear in the order they run, and a stage that did not run is still
|
|
123
|
+
* listed with `result: "skipped"`. That is the point of the array: a reader must
|
|
124
|
+
* be able to tell "the device accepted it" from "the device was never asked".
|
|
125
|
+
*/
|
|
126
|
+
export interface EnvelopeValidationStage {
|
|
127
|
+
stage: "offline" | "device";
|
|
128
|
+
/** Validator identity, e.g. `offline explain (canonicalizer)` or `:put [:parse]`. */
|
|
129
|
+
source: string;
|
|
130
|
+
result: "passed" | "failed" | "skipped";
|
|
131
|
+
/** Why a stage was skipped, when the reason is not simply `validate=false`. */
|
|
132
|
+
reason?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mutable sink that lets an error path report the validation that ACTUALLY ran.
|
|
137
|
+
*
|
|
138
|
+
* `runResolvedExecute` / `runResolvedApi` finish the gate and then do the real
|
|
139
|
+
* work, and the error envelope is built by a *different* function after the
|
|
140
|
+
* throw unwinds past them. Without this, a `routeros/*` fault on the run — which
|
|
141
|
+
* happens with both gate stages already green — got a reconstructed
|
|
142
|
+
* `result: "failed"` that erased the real outcome. The runner writes
|
|
143
|
+
* {@link ValidationTrace.validation} the moment the gate returns; the error
|
|
144
|
+
* builder prefers it over anything it could guess (GH#354, PR #356 review).
|
|
145
|
+
*/
|
|
146
|
+
export interface ValidationTrace {
|
|
147
|
+
/** The completed gate result, set once validation finishes. */
|
|
148
|
+
validation?: EnvelopeValidationMeta;
|
|
149
|
+
/**
|
|
150
|
+
* The stage-1 verdict, recorded as soon as stage 1 returns — before stage 2
|
|
151
|
+
* can throw. `warn` is an abstention, and an error envelope that reported it
|
|
152
|
+
* as `passed` would be telling a consumer the analyzer read bytes it declined
|
|
153
|
+
* to judge.
|
|
154
|
+
*/
|
|
155
|
+
offlineVerdict?: "pass" | "warn";
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
export interface EnvelopeValidationMeta {
|
|
112
159
|
enabled: boolean;
|
|
113
160
|
/** Validator identity, e.g. `/console/inspect` or `:put [:parse]`. */
|
|
@@ -118,6 +165,15 @@ export interface EnvelopeValidationMeta {
|
|
|
118
165
|
/** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
|
|
119
166
|
semantic?: boolean | "not-applicable";
|
|
120
167
|
availableAttributes?: readonly string[];
|
|
168
|
+
/**
|
|
169
|
+
* Per-stage detail for the gate described in
|
|
170
|
+
* `docs/CONSTITUTION.md` → "Validation is the product".
|
|
171
|
+
*
|
|
172
|
+
* The flat `source` / `syntax` / `semantic` fields above stay the summary
|
|
173
|
+
* view and keep their meaning; this is the breakdown behind them. Absent on
|
|
174
|
+
* surfaces that have no RouterOS-command gate at all (`btest`).
|
|
175
|
+
*/
|
|
176
|
+
stages?: readonly EnvelopeValidationStage[];
|
|
121
177
|
}
|
|
122
178
|
|
|
123
179
|
export interface EnvelopeTimingMeta {
|
|
@@ -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:
|
|
@@ -514,7 +554,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
514
554
|
},
|
|
515
555
|
{
|
|
516
556
|
code: "validation/syntax",
|
|
517
|
-
summary:
|
|
557
|
+
summary:
|
|
558
|
+
"The command syntax was rejected by the validation gate — offline analysis, or the RouterOS :parse probe.",
|
|
518
559
|
},
|
|
519
560
|
{
|
|
520
561
|
code: "validation/unknown-attribute",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **script-vs-structured execution gate** and its canonical command shape.
|
|
3
|
+
*
|
|
4
|
+
* `execute.ts` owned this pair, which put every consumer of the gate — the MCP
|
|
5
|
+
* tool surface and `explain`'s offline analysis among them — one import away
|
|
6
|
+
* from the transport stack (`mac-telnet` -> `node:dgram`, `native-api` ->
|
|
7
|
+
* `node:crypto`). The functions themselves are pure string work, so they live in
|
|
8
|
+
* `src/core/` and `execute.ts` re-exports them: the public API, the locked
|
|
9
|
+
* contract table in `test/unit/execute-canonicalize-contract.test.ts`, and the
|
|
10
|
+
* gate's behavior are all unchanged by the move (#312).
|
|
11
|
+
*
|
|
12
|
+
* Nothing here reads settings, the filesystem, CDB, or a device. Changing that
|
|
13
|
+
* re-couples `explain`'s browser entry to the transport graph.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface CanonicalExecuteCommand {
|
|
17
|
+
mode: "structured" | "script";
|
|
18
|
+
input: string;
|
|
19
|
+
path: string;
|
|
20
|
+
verb: string;
|
|
21
|
+
attributes: Record<string, string>;
|
|
22
|
+
queries: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The **script-vs-structured execution gate** — centrs's load-bearing
|
|
27
|
+
* discriminator (with {@link isWriteShaped}) for which validation runs and
|
|
28
|
+
* whether the write-confirmation prompt fires. centrs owns this gate; the shared,
|
|
29
|
+
* prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
|
|
30
|
+
* publish is for *canonicalization*, never the structured-mode predicate —
|
|
31
|
+
* widening what counts as `structured` is a product regression. Behavior is
|
|
32
|
+
* pinned by `test/unit/execute-canonicalize-contract.test.ts`.
|
|
33
|
+
*
|
|
34
|
+
* centrs deliberately does **not** vendor the shared parser yet. Preconditions
|
|
35
|
+
* for adopting it: (1) that contract stays green (no gate widening); (2) the
|
|
36
|
+
* vendored file is clean under centrs's strict `tsconfig` (or explicitly
|
|
37
|
+
* quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
|
|
38
|
+
* the same parser shape, so it is genuinely shared, not a premature first copy.
|
|
39
|
+
*/
|
|
40
|
+
export function canonicalizeExecuteCommand(
|
|
41
|
+
input: string,
|
|
42
|
+
): CanonicalExecuteCommand {
|
|
43
|
+
const asScript = (): CanonicalExecuteCommand => ({
|
|
44
|
+
mode: "script",
|
|
45
|
+
input,
|
|
46
|
+
path: "",
|
|
47
|
+
verb: "",
|
|
48
|
+
attributes: {},
|
|
49
|
+
queries: [],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const trimmed = input.trim();
|
|
53
|
+
if (!trimmed.startsWith("/")) {
|
|
54
|
+
return asScript();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tokens = tokenizeRouterOsCli(trimmed);
|
|
58
|
+
const pathToken = tokens[0] ?? "";
|
|
59
|
+
const pathParts = pathToken.split("/").filter(Boolean);
|
|
60
|
+
if (pathParts.length < 2) {
|
|
61
|
+
return asScript();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const verb = pathParts.at(-1) ?? "";
|
|
65
|
+
const path = `/${pathParts.slice(0, -1).join("/")}`;
|
|
66
|
+
const attributes: Record<string, string> = {};
|
|
67
|
+
const queries: string[] = [];
|
|
68
|
+
for (const token of tokens.slice(1)) {
|
|
69
|
+
// `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
|
|
70
|
+
// represented as a structured attribute map: the inner command contains
|
|
71
|
+
// spaces this tokenizer splits on, which would mangle a write-shaped
|
|
72
|
+
// command into corrupt key=value pairs. Fall back to the raw-script path
|
|
73
|
+
// so RouterOS evaluates the subshell with its real semantics.
|
|
74
|
+
if (token.includes("[") || token.includes("]")) {
|
|
75
|
+
return asScript();
|
|
76
|
+
}
|
|
77
|
+
if (token.startsWith("?")) {
|
|
78
|
+
queries.push(token);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const separator = token.indexOf("=");
|
|
82
|
+
if (separator <= 0) {
|
|
83
|
+
return asScript();
|
|
84
|
+
}
|
|
85
|
+
attributes[token.slice(0, separator)] = token.slice(separator + 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
mode: "structured",
|
|
90
|
+
input,
|
|
91
|
+
path,
|
|
92
|
+
verb,
|
|
93
|
+
attributes,
|
|
94
|
+
queries,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
99
|
+
return (
|
|
100
|
+
command.mode === "structured" &&
|
|
101
|
+
["add", "set", "remove"].includes(command.verb)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function tokenizeRouterOsCli(input: string): string[] {
|
|
106
|
+
const tokens: string[] = [];
|
|
107
|
+
let current = "";
|
|
108
|
+
let quote: '"' | "'" | undefined;
|
|
109
|
+
let escaped = false;
|
|
110
|
+
for (const char of input) {
|
|
111
|
+
if (escaped) {
|
|
112
|
+
current += char;
|
|
113
|
+
escaped = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (char === "\\") {
|
|
117
|
+
escaped = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (quote) {
|
|
121
|
+
if (char === quote) {
|
|
122
|
+
quote = undefined;
|
|
123
|
+
} else {
|
|
124
|
+
current += char;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (char === '"' || char === "'") {
|
|
129
|
+
quote = char;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (/\s/.test(char)) {
|
|
133
|
+
if (current.length > 0) {
|
|
134
|
+
tokens.push(current);
|
|
135
|
+
current = "";
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
current += char;
|
|
140
|
+
}
|
|
141
|
+
if (escaped) {
|
|
142
|
+
current += "\\";
|
|
143
|
+
}
|
|
144
|
+
if (current.length > 0) {
|
|
145
|
+
tokens.push(current);
|
|
146
|
+
}
|
|
147
|
+
return tokens;
|
|
148
|
+
}
|
package/src/core/inspect.ts
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* (path tokenizing, the comma-path join, node-type predicates, completion-name
|
|
6
6
|
* extraction). This module is the single home so a third consumer — `api`, and a
|
|
7
7
|
* future `explain`/`check` — reuses one grounded implementation instead of
|
|
8
|
-
* forking a fourth. It
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* forking a fourth. It holds the transport-agnostic *primitives* plus the one
|
|
9
|
+
* discovery strategy that is shared verbatim: {@link inspectArgumentNames},
|
|
10
|
+
* command-level `request=child` only, used by `execute` and `api`. Strategies
|
|
11
|
+
* that differ stay in the command — `retrieve` probes print/get support and
|
|
12
|
+
* reads argument-level completion (`print,proplist` / `get,value-name`) with a
|
|
13
|
+
* child fallback.
|
|
14
|
+
*
|
|
15
|
+
* Command-level `request=completion` is not part of any argument-discovery
|
|
16
|
+
* strategy: on RouterOS 7.12.2 it hangs the REST handler and closes the
|
|
17
|
+
* native-API connection. See {@link inspectArgumentNames}.
|
|
12
18
|
*
|
|
13
19
|
* Grounding: the request modes mirror `tikoci/lsp-routeros-ts`
|
|
14
20
|
* (`server/src/routeros.ts` `InspectRequest`). The behavioral facts (the array-
|
|
@@ -131,6 +137,32 @@ export async function inspectChildren(
|
|
|
131
137
|
)) as InspectChildItem[];
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Return the argument names exposed by `request=child` at a command path.
|
|
142
|
+
*
|
|
143
|
+
* Do not supplement this with a command-level `request=completion`: RouterOS
|
|
144
|
+
* 7.12.2 hangs the REST handler and closes the native-API connection for that
|
|
145
|
+
* request shape, while `child` returns the complete argument list immediately.
|
|
146
|
+
* Argument-level completion (for example `print,proplist`) is a distinct probe
|
|
147
|
+
* and remains available through {@link inspectCompletions}.
|
|
148
|
+
*/
|
|
149
|
+
export async function inspectArgumentNames(
|
|
150
|
+
backend: InspectBackend,
|
|
151
|
+
tokens: readonly string[],
|
|
152
|
+
): Promise<string[]> {
|
|
153
|
+
const children = await inspectChildren(backend, tokens);
|
|
154
|
+
return [
|
|
155
|
+
...new Set(
|
|
156
|
+
children
|
|
157
|
+
.filter(isArgumentNode)
|
|
158
|
+
.map((child) => child.name)
|
|
159
|
+
.filter(
|
|
160
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
].sort();
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
/** `request=completion` for a token path. */
|
|
135
167
|
export async function inspectCompletions(
|
|
136
168
|
backend: InspectBackend,
|