@tikoci/centrs 0.1.4 → 0.1.7
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 +417 -1
- package/GLOSSARY.txt +40 -1
- package/README.md +16 -8
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +50 -10
- package/commands/api/examples.md +100 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +68 -25
- package/commands/execute/examples.md +168 -7
- package/commands/explain/README.md +1206 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +9 -5
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/examples.md +20 -0
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +3 -3
- package/docs/CONSTITUTION.md +83 -21
- package/docs/MATRIX.md +89 -52
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +346 -75
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/devices.ts +16 -3
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +16 -1
- package/src/core/execute-command.ts +455 -0
- package/src/core/inspect.ts +58 -26
- package/src/core/routeros-errors.ts +66 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +19 -5
- package/src/execute.ts +247 -295
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +21 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +18 -63
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export const centrsVersion = "0.1.0";
|
|
2
|
-
|
|
3
1
|
export {
|
|
4
2
|
type ApiEnvelope,
|
|
5
3
|
type ApiErrorEnvelope,
|
|
@@ -81,6 +79,7 @@ export {
|
|
|
81
79
|
type EnvelopeMeta,
|
|
82
80
|
type EnvelopeTargetMeta,
|
|
83
81
|
type EnvelopeValidationMeta,
|
|
82
|
+
type EnvelopeValidationStage,
|
|
84
83
|
type FanoutData,
|
|
85
84
|
type FanoutSummary,
|
|
86
85
|
normalizeWarnings,
|
|
@@ -110,6 +109,7 @@ export {
|
|
|
110
109
|
type InspectChildItem,
|
|
111
110
|
type InspectCompletionItem,
|
|
112
111
|
type InspectRequestKind,
|
|
112
|
+
inspectArgumentNames,
|
|
113
113
|
inspectChildren,
|
|
114
114
|
inspectChildrenOrEmpty,
|
|
115
115
|
inspectCompletions,
|
|
@@ -136,6 +136,7 @@ export {
|
|
|
136
136
|
versionFactFromComment,
|
|
137
137
|
versionInRange,
|
|
138
138
|
} from "./core/preflight.ts";
|
|
139
|
+
export { centrsVersion } from "./core/version.ts";
|
|
139
140
|
export {
|
|
140
141
|
encodeMndpPacket,
|
|
141
142
|
MNDP_BROADCAST_ADDRESS,
|
|
@@ -202,6 +203,7 @@ export {
|
|
|
202
203
|
type DevicesCommand,
|
|
203
204
|
type DevicesEnvelope,
|
|
204
205
|
type DevicesGroupSummary,
|
|
206
|
+
type DevicesInitData,
|
|
205
207
|
type DevicesListItem,
|
|
206
208
|
type DevicesMutationData,
|
|
207
209
|
type DevicesMutationEnvelope,
|
|
@@ -214,6 +216,7 @@ export {
|
|
|
214
216
|
defaultCdbPath,
|
|
215
217
|
devicesOutputFormats,
|
|
216
218
|
editInteractiveOnlyError,
|
|
219
|
+
initCdb,
|
|
217
220
|
type LoadCdbOptions,
|
|
218
221
|
type LoadedCdb,
|
|
219
222
|
listDevices,
|
|
@@ -305,13 +308,20 @@ export {
|
|
|
305
308
|
// relative to the statement it was handed; the `Token` is that rebased into
|
|
306
309
|
// document space by `explainCommand`. A consumer reading `explainCommand`'s
|
|
307
310
|
// result wants the latter.
|
|
311
|
+
//
|
|
312
|
+
// `lexExplainArgumentTokens` is the skip-tolerant reading (#316) and is
|
|
313
|
+
// exported for the same reason `lexExplainValueAnchors` is: a consumer building
|
|
314
|
+
// its own token-level rule needs the reading a rule wants, not the one a
|
|
315
|
+
// renderer wants. Its `undecided` tokens carry no `value` and never become one.
|
|
308
316
|
export {
|
|
309
317
|
type Argument as ExplainArgument,
|
|
310
318
|
type ArgumentKind as ExplainArgumentKind,
|
|
311
319
|
type ArgumentReading as ExplainArgumentReading,
|
|
312
320
|
type ArgumentsRead as ExplainArgumentsRead,
|
|
313
321
|
type ArgumentsUnread as ExplainArgumentsUnread,
|
|
322
|
+
type ArgumentTokenReading as ExplainArgumentTokenReading,
|
|
314
323
|
lexArguments as lexExplainArguments,
|
|
324
|
+
lexArgumentTokens as lexExplainArgumentTokens,
|
|
315
325
|
lexValueAnchors as lexExplainValueAnchors,
|
|
316
326
|
type ValueAnchor as ExplainValueAnchor,
|
|
317
327
|
type ValueAnchorKind as ExplainValueAnchorKind,
|
|
@@ -478,6 +488,14 @@ export {
|
|
|
478
488
|
explainOutputFormats,
|
|
479
489
|
renderExplainEnvelope,
|
|
480
490
|
} from "./explain.ts";
|
|
491
|
+
export {
|
|
492
|
+
assertOfflineSyntax,
|
|
493
|
+
isOfflineGateRejection,
|
|
494
|
+
OFFLINE_GATE_SOURCE,
|
|
495
|
+
type OfflineGateFinding,
|
|
496
|
+
type OfflineGateResult,
|
|
497
|
+
type ValidationStageName,
|
|
498
|
+
} from "./offline-gate.ts";
|
|
481
499
|
export {
|
|
482
500
|
type ApiVerb,
|
|
483
501
|
createProtocolAdapter,
|
|
@@ -672,6 +690,7 @@ export {
|
|
|
672
690
|
transferFanout,
|
|
673
691
|
} from "./transfer-fanout.ts";
|
|
674
692
|
|
|
693
|
+
import { centrsVersion } from "./core/version.ts";
|
|
675
694
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
676
695
|
|
|
677
696
|
export const plannedSurfaces = ["api", "cli", "tui", "mcp", "proxy"] as const;
|
package/src/mcp/server.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
12
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
13
|
+
import { centrsVersion } from "../core/version.ts";
|
|
13
14
|
import { type CentrsMcpConfig, resolveMcpConfig } from "./config.ts";
|
|
14
15
|
import {
|
|
15
16
|
buildDevicesResource,
|
|
@@ -79,7 +80,7 @@ export function createCentrsMcpServer(
|
|
|
79
80
|
config: CentrsMcpConfig = resolveMcpConfig(),
|
|
80
81
|
): McpServer {
|
|
81
82
|
const server = new McpServer(
|
|
82
|
-
{ name: "centrs-mcp", version:
|
|
83
|
+
{ name: "centrs-mcp", version: centrsVersion },
|
|
83
84
|
{ instructions: SERVER_INSTRUCTIONS },
|
|
84
85
|
);
|
|
85
86
|
|
package/src/mcp/tools.ts
CHANGED
|
@@ -352,7 +352,7 @@ export const executeInputShape = {
|
|
|
352
352
|
.boolean()
|
|
353
353
|
.optional()
|
|
354
354
|
.describe(
|
|
355
|
-
"Required true for write-shaped commands
|
|
355
|
+
"Required true for write-shaped commands, including selector/positional and non-CRUD mutations. Reads ignore it.",
|
|
356
356
|
),
|
|
357
357
|
via: z.string().optional().describe("Override transport."),
|
|
358
358
|
timeout: z
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1 of the validation gate: the offline analyzer in front of the device
|
|
3
|
+
* preflight (GH#354).
|
|
4
|
+
*
|
|
5
|
+
* centrs used to run two validators that did not know about each other — the
|
|
6
|
+
* device preflight (`:put [:parse …]` plus `/console/inspect request=child`)
|
|
7
|
+
* that `execute` / `api` / `validate` share, and offline `explain`, which is
|
|
8
|
+
* byte-accurate and corpus-gated but took no part in validation at all. This
|
|
9
|
+
* module is the seam that joins them: every CLI-shaped input is analyzed here
|
|
10
|
+
* first, with no connection, and only then handed to the device stage.
|
|
11
|
+
*
|
|
12
|
+
* Three things that buys, in the order they matter:
|
|
13
|
+
*
|
|
14
|
+
* 1. A syntax fault is rejected with a **byte span** and **no round trip** — the
|
|
15
|
+
* protocol adapter is never even constructed.
|
|
16
|
+
* 2. The device stage narrows to the semantic half it is actually needed for
|
|
17
|
+
* (`:parse` is name-level only; `/console/inspect` owns the attribute set).
|
|
18
|
+
* 3. Where the device probe is unsafe or unavailable — the 7.12.2 class of
|
|
19
|
+
* problem in GH#343 — the gate degrades to a real gate instead of to nothing.
|
|
20
|
+
*
|
|
21
|
+
* **A clean offline pass is necessary, never sufficient.** `explain` publishes
|
|
22
|
+
* `runtimeAcceptance: "not-proven"` for a reason: `/console/inspect` accepts
|
|
23
|
+
* forms the runtime rejects, and the analyzer has no per-menu schema (decision 3
|
|
24
|
+
* — no offline schema snapshot). The device stage is not optional and this
|
|
25
|
+
* module never reports success on its behalf. `verdict: "warn"` is an
|
|
26
|
+
* ABSTENTION, not a rejection, and passes through for the same reason.
|
|
27
|
+
*
|
|
28
|
+
* `--validate=false` disables BOTH stages — one flag, one meaning. That was
|
|
29
|
+
* settled with the `--raw` precedence layer on GH#154 and is why callers gate
|
|
30
|
+
* this on the same resolved `validate` setting as the device stage; the escape
|
|
31
|
+
* hatch has to cover a stage-1 defect too (GH#355 is a live example of the
|
|
32
|
+
* analyzer being wrong in the other direction).
|
|
33
|
+
*
|
|
34
|
+
* The offending byte range rides `context.span`, never {@link CentrsError}'s
|
|
35
|
+
* `position`. `RouterOsErrorPosition` is documented as RouterOS's own
|
|
36
|
+
* authoritative 1-based byte column, "carried verbatim; never re-derived"
|
|
37
|
+
* (`errors.ts`) — synthesizing one from offline analysis would make a
|
|
38
|
+
* centrs-derived offset indistinguishable from a device-reported one.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { CentrsError } from "./errors.ts";
|
|
42
|
+
import { type ExplainDiagnostic, explainCommand } from "./explain.ts";
|
|
43
|
+
|
|
44
|
+
/** Validator identity for `meta.validation` and error context. */
|
|
45
|
+
export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
|
|
46
|
+
|
|
47
|
+
/** Which stage of the two-stage gate produced a verdict. */
|
|
48
|
+
export type ValidationStageName = "offline" | "device";
|
|
49
|
+
|
|
50
|
+
/** One rejected span, flattened out of {@link ExplainDiagnostic} for the error context. */
|
|
51
|
+
export interface OfflineGateFinding {
|
|
52
|
+
/** The `explain/<analyzer>/<slug>` diagnostic code. */
|
|
53
|
+
code: string;
|
|
54
|
+
message: string;
|
|
55
|
+
/** Byte offsets into the analyzed input, end-exclusive. */
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Outcome of a stage-1 run that did not reject. */
|
|
61
|
+
export interface OfflineGateResult {
|
|
62
|
+
/**
|
|
63
|
+
* `pass` — the analyzer read the whole input. `warn` — it ABSTAINED on part
|
|
64
|
+
* of it (an unresolved menu, say). Both proceed to the device stage; only
|
|
65
|
+
* `fail` throws.
|
|
66
|
+
*/
|
|
67
|
+
verdict: "pass" | "warn";
|
|
68
|
+
/** Warning-severity diagnostics, kept so a caller can surface the abstention. */
|
|
69
|
+
warnings: readonly OfflineGateFinding[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findingOf(diagnostic: ExplainDiagnostic): OfflineGateFinding {
|
|
73
|
+
return {
|
|
74
|
+
code: diagnostic.code,
|
|
75
|
+
message: diagnostic.message,
|
|
76
|
+
start: diagnostic.span.start,
|
|
77
|
+
end: diagnostic.span.end,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run the offline stage over a RouterOS CLI string.
|
|
83
|
+
*
|
|
84
|
+
* Throws `validation/syntax` when the analyzer rejects the input; returns the
|
|
85
|
+
* verdict otherwise. Never opens a connection and never throws for any other
|
|
86
|
+
* reason — `explainCommand` itself is documented as total.
|
|
87
|
+
*
|
|
88
|
+
* `surface` names the caller in the error context (`execute`, `api /execute`) so
|
|
89
|
+
* a reader can tell which command's gate spoke; `via` is the resolved protocol
|
|
90
|
+
* when the caller has one, purely for context.
|
|
91
|
+
*/
|
|
92
|
+
export function assertOfflineSyntax(
|
|
93
|
+
command: string,
|
|
94
|
+
options: { surface: string; via?: string },
|
|
95
|
+
): OfflineGateResult {
|
|
96
|
+
const analysis = explainCommand(command);
|
|
97
|
+
const errors = analysis.diagnostics.filter(
|
|
98
|
+
(diagnostic) => diagnostic.severity === "error",
|
|
99
|
+
);
|
|
100
|
+
const warnings = analysis.diagnostics
|
|
101
|
+
.filter((diagnostic) => diagnostic.severity === "warning")
|
|
102
|
+
.map(findingOf);
|
|
103
|
+
|
|
104
|
+
if (analysis.verdict !== "fail") {
|
|
105
|
+
return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A `fail` verdict always carries at least one error diagnostic, but read it
|
|
109
|
+
// defensively: an empty list would otherwise produce an error that says
|
|
110
|
+
// nothing about what was wrong, which is worse than a generic summary.
|
|
111
|
+
const findings = errors.map(findingOf);
|
|
112
|
+
const first = findings[0];
|
|
113
|
+
throw new CentrsError({
|
|
114
|
+
code: "validation/syntax",
|
|
115
|
+
summary: first
|
|
116
|
+
? `RouterOS syntax rejected by offline analysis: ${first.message}`
|
|
117
|
+
: "RouterOS syntax rejected by offline analysis.",
|
|
118
|
+
remediation:
|
|
119
|
+
"Run `centrs explain '<command>'` to see the offending bytes in context, " +
|
|
120
|
+
"fix the syntax, then retry. `--validate=false` bypasses both validation " +
|
|
121
|
+
"stages if you are deliberately probing a RouterOS edge centrs reads wrong.",
|
|
122
|
+
context: {
|
|
123
|
+
command,
|
|
124
|
+
validationStage: "offline" satisfies ValidationStageName,
|
|
125
|
+
validationSource: OFFLINE_GATE_SOURCE,
|
|
126
|
+
surface: options.surface,
|
|
127
|
+
...(options.via ? { via: options.via } : {}),
|
|
128
|
+
...(first ? { span: { start: first.start, end: first.end } } : {}),
|
|
129
|
+
diagnostics: findings,
|
|
130
|
+
},
|
|
131
|
+
causeData: first?.code ?? "offline analysis rejected the input",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* True when `error` is the rejection {@link assertOfflineSyntax} throws.
|
|
137
|
+
*
|
|
138
|
+
* Callers use this to build honest envelope metadata: on a stage-1 rejection the
|
|
139
|
+
* device stage did not run, and no connection was opened, so the meta must not
|
|
140
|
+
* claim `:parse` or `/console/inspect` failed.
|
|
141
|
+
*/
|
|
142
|
+
export function isOfflineGateRejection(error: unknown): boolean {
|
|
143
|
+
return (
|
|
144
|
+
error instanceof CentrsError &&
|
|
145
|
+
error.code === "validation/syntax" &&
|
|
146
|
+
(error.context?.["validationStage"] as string | undefined) === "offline"
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/protocols/adapter.ts
CHANGED
|
@@ -152,6 +152,14 @@ export interface ProtocolListenOptions {
|
|
|
152
152
|
* after the follow is established, instead of a blind timer.
|
|
153
153
|
*/
|
|
154
154
|
onListening?: () => void;
|
|
155
|
+
/**
|
|
156
|
+
* How long to wait for RouterOS to acknowledge `/cancel` after `signal`
|
|
157
|
+
* aborts before ending the listen locally. Native API defaults to the
|
|
158
|
+
* adapter's `timeoutMs`, the same bound as any other reply wait.
|
|
159
|
+
*/
|
|
160
|
+
cancelGraceMs?: number;
|
|
161
|
+
/** Fired when that wait expires without the router's acknowledgement. */
|
|
162
|
+
onCancelUnacknowledged?: () => void;
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
/**
|
|
@@ -733,7 +741,10 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
733
741
|
command.proplist = request.proplist;
|
|
734
742
|
}
|
|
735
743
|
const session = await this.connect();
|
|
736
|
-
for await (const reply of session.listen(command,
|
|
744
|
+
for await (const reply of session.listen(command, {
|
|
745
|
+
...options,
|
|
746
|
+
cancelGraceMs: options.cancelGraceMs ?? this.config.timeoutMs,
|
|
747
|
+
})) {
|
|
737
748
|
// Each `!re` becomes a rest-style record (string values; `.dead` preserved).
|
|
738
749
|
yield { ...reply.attributes };
|
|
739
750
|
}
|
|
@@ -627,14 +627,16 @@ export function parseScriptFor(cli: string): string {
|
|
|
627
627
|
* with RouterOS's position suffix. All 425 corpus rows of this shape are
|
|
628
628
|
* the entire return; none appear embedded. Requiring both line-start and
|
|
629
629
|
* the `(line N column M)` suffix is what separates a real verdict from
|
|
630
|
-
* echoed body text.
|
|
630
|
+
* echoed body text. The pinned 7.23.5/7.24.2/7.25beta3 captures add no
|
|
631
|
+
* new heads beyond `syntax error`, `expected …` and `missing …`
|
|
632
|
+
* (`missing closing brace`, `missing value for where`; GH#375).
|
|
631
633
|
*/
|
|
632
634
|
const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
|
|
633
635
|
const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
|
|
634
636
|
const BARE_BAD_PARAMETER =
|
|
635
637
|
/^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
|
|
636
638
|
const BARE_SYNTAX =
|
|
637
|
-
/^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
639
|
+
/^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
638
640
|
|
|
639
641
|
/**
|
|
640
642
|
* Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
|
|
@@ -646,8 +648,8 @@ const BARE_SYNTAX =
|
|
|
646
648
|
* RouterOS emits them (see the grammar note above): wrapped as
|
|
647
649
|
* `(evl bad parameter <name> (line N column M) /path)` /
|
|
648
650
|
* `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
|
|
649
|
-
* line-initial as `expected … (line N column M)` / `
|
|
650
|
-
* M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
651
|
+
* line-initial as `expected … (line N column M)` / `missing … (line N column
|
|
652
|
+
* M)` / `syntax error (line N column M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
651
653
|
* (name-level) gate on every transport — REST and native API expose the same text
|
|
652
654
|
* in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
|
|
653
655
|
*
|
|
@@ -635,10 +635,21 @@ export class NativeApiSession {
|
|
|
635
635
|
* trap/`!done` are not awaited) and the generator returns.
|
|
636
636
|
* A non-`interrupted` trap (e.g. a bad path) or `!fatal`/transport closure
|
|
637
637
|
* throws. CHR-grounded (7.23.1): see `commands/api/AGENTS.md`.
|
|
638
|
+
*
|
|
639
|
+
* After an abort, `cancelGraceMs` bounds the wait for that acknowledgement: a
|
|
640
|
+
* peer that never answers `/cancel` would otherwise hold the generator open
|
|
641
|
+
* forever (#385). On expiry the generator ends without the router's
|
|
642
|
+
* `!done` and `onCancelUnacknowledged` fires, so the caller can close the
|
|
643
|
+
* session and report that the stop was local, not acknowledged.
|
|
638
644
|
*/
|
|
639
645
|
async *listen(
|
|
640
646
|
command: NativeApiCommand,
|
|
641
|
-
options: {
|
|
647
|
+
options: {
|
|
648
|
+
signal?: AbortSignal;
|
|
649
|
+
onListening?: () => void;
|
|
650
|
+
cancelGraceMs?: number;
|
|
651
|
+
onCancelUnacknowledged?: () => void;
|
|
652
|
+
} = {},
|
|
642
653
|
): AsyncGenerator<ApiReply, void, void> {
|
|
643
654
|
if (this.closed) {
|
|
644
655
|
throw (
|
|
@@ -702,10 +713,24 @@ export class NativeApiSession {
|
|
|
702
713
|
// can act on a real barrier rather than a blind timer.
|
|
703
714
|
options.onListening?.();
|
|
704
715
|
|
|
705
|
-
|
|
716
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
717
|
+
const onAbort = (): void => {
|
|
718
|
+
this.cancel(tag);
|
|
719
|
+
if (options.cancelGraceMs === undefined) return;
|
|
720
|
+
graceTimer = setTimeout(() => {
|
|
721
|
+
if (ended) return;
|
|
722
|
+
// Stop routing this tag and drop anything queued: a peer that ignores
|
|
723
|
+
// `/cancel` but keeps sending frames must not keep the loop draining.
|
|
724
|
+
this.subscriptions.delete(tag);
|
|
725
|
+
queue.length = 0;
|
|
726
|
+
ended = true;
|
|
727
|
+
options.onCancelUnacknowledged?.();
|
|
728
|
+
signalWake();
|
|
729
|
+
}, options.cancelGraceMs);
|
|
730
|
+
};
|
|
706
731
|
if (options.signal) {
|
|
707
732
|
if (options.signal.aborted) {
|
|
708
|
-
|
|
733
|
+
onAbort();
|
|
709
734
|
} else {
|
|
710
735
|
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
711
736
|
}
|
|
@@ -738,6 +763,7 @@ export class NativeApiSession {
|
|
|
738
763
|
throw this.trapToError(command.command, trap);
|
|
739
764
|
}
|
|
740
765
|
} finally {
|
|
766
|
+
clearTimeout(graceTimer);
|
|
741
767
|
options.signal?.removeEventListener("abort", onAbort);
|
|
742
768
|
this.subscriptions.delete(tag);
|
|
743
769
|
// Consumer broke out early (e.g. --count reached): tell the router to stop.
|
package/src/resolver/target.ts
CHANGED
|
@@ -428,20 +428,40 @@ export function resolveAuth(
|
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
export function parseHostCandidate(value: string): URL {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
remediation:
|
|
438
|
-
"Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
return url;
|
|
431
|
+
const hasScheme = /^https?:\/\//i.test(value);
|
|
432
|
+
let url: URL;
|
|
433
|
+
try {
|
|
434
|
+
url = new URL(hasScheme ? value : `http://${value}`);
|
|
435
|
+
} catch (cause) {
|
|
436
|
+
throw invalidTargetError(value, cause);
|
|
442
437
|
}
|
|
438
|
+
if (hasScheme && url.pathname !== "/" && url.pathname !== "") {
|
|
439
|
+
throw new CentrsError({
|
|
440
|
+
code: "input/invalid-target",
|
|
441
|
+
summary: `Target URL must not include a path. Received: ${value}`,
|
|
442
|
+
remediation:
|
|
443
|
+
"Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return url;
|
|
447
|
+
}
|
|
443
448
|
|
|
444
|
-
|
|
449
|
+
/**
|
|
450
|
+
* A `<router>` that is not a CDB handle and does not parse as a host (#381).
|
|
451
|
+
* `quickchr:NAME` is the likely guess for a quickchr VM, so it names the flag.
|
|
452
|
+
*/
|
|
453
|
+
function invalidTargetError(value: string, cause: unknown): CentrsError {
|
|
454
|
+
const quickchrGuess = /^quickchr:(.+)$/i.exec(value)?.[1];
|
|
455
|
+
return new CentrsError({
|
|
456
|
+
code: "input/invalid-target",
|
|
457
|
+
summary: `Target is not a known device or a valid host: ${value}`,
|
|
458
|
+
remediation:
|
|
459
|
+
quickchrGuess !== undefined
|
|
460
|
+
? `Select a quickchr VM with the flag: \`--quickchr ${quickchrGuess}\` (there is no \`quickchr:\` target prefix).`
|
|
461
|
+
: "Pass a CDB target or identity (see `centrs devices list`), an IP or hostname with optional `:port`, or an `http(s)://host[:port]` base URL.",
|
|
462
|
+
context: { target: value },
|
|
463
|
+
cause,
|
|
464
|
+
});
|
|
445
465
|
}
|
|
446
466
|
|
|
447
467
|
function readPort(parsedUrl: URL, scheme: "http" | "https"): number {
|
package/src/retrieve-fanout.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
runFanout,
|
|
47
47
|
summarizeFanout,
|
|
48
48
|
} from "./core/fanout.ts";
|
|
49
|
+
import { toYaml } from "./core/yaml.ts";
|
|
49
50
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
50
51
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
51
52
|
import {
|
|
@@ -71,7 +72,6 @@ import {
|
|
|
71
72
|
resolveRetrieveGlobalContext,
|
|
72
73
|
resolveRetrieveRequest,
|
|
73
74
|
runResolvedRetrieve,
|
|
74
|
-
toYaml,
|
|
75
75
|
validateRetrieveRequestShape,
|
|
76
76
|
} from "./retrieve.ts";
|
|
77
77
|
|
package/src/retrieve.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "./core/envelope.ts";
|
|
9
9
|
import {
|
|
10
10
|
extractCompletionNames,
|
|
11
|
+
inspectArgumentNames,
|
|
11
12
|
inspectChildren,
|
|
12
13
|
inspectChildrenOrEmpty,
|
|
13
14
|
inspectCompletions,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
isCommandNode,
|
|
16
17
|
pathTokens,
|
|
17
18
|
} from "./core/inspect.ts";
|
|
19
|
+
import { toYaml } from "./core/yaml.ts";
|
|
18
20
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
19
21
|
import {
|
|
20
22
|
createProtocolAdapter,
|
|
@@ -123,6 +125,7 @@ export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
|
|
|
123
125
|
|
|
124
126
|
interface RetrieveInspection {
|
|
125
127
|
command: "get" | "print";
|
|
128
|
+
singleton: boolean;
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
export interface ResolvedRetrieveRequest {
|
|
@@ -239,7 +242,7 @@ export async function runResolvedRetrieve(
|
|
|
239
242
|
}
|
|
240
243
|
}
|
|
241
244
|
|
|
242
|
-
const data = await executeRetrieve(resolved, backend);
|
|
245
|
+
const data = await executeRetrieve(resolved, backend, inspection);
|
|
243
246
|
const envelope = buildSuccessEnvelope(
|
|
244
247
|
resolved,
|
|
245
248
|
{
|
|
@@ -998,9 +1001,18 @@ async function inspectRetrievePath(
|
|
|
998
1001
|
});
|
|
999
1002
|
}
|
|
1000
1003
|
|
|
1001
|
-
|
|
1004
|
+
// A singleton's `get` takes only `value-name`; a list menu's also takes
|
|
1005
|
+
// `number` (CHR 7.23.7: /tool/romon, /ip/dns, /system/identity vs
|
|
1006
|
+
// /ip/address). A hardcoded path list missed every other singleton and
|
|
1007
|
+
// validated its properties against `print`'s flags (issue #377).
|
|
1008
|
+
const singleton =
|
|
1009
|
+
supportsGet &&
|
|
1010
|
+
!(await inspectArgumentNames(backend, [...tokens, "get"])).includes(
|
|
1011
|
+
"number",
|
|
1012
|
+
);
|
|
1002
1013
|
return {
|
|
1003
|
-
command: singleton
|
|
1014
|
+
command: singleton ? "get" : "print",
|
|
1015
|
+
singleton,
|
|
1004
1016
|
};
|
|
1005
1017
|
}
|
|
1006
1018
|
|
|
@@ -1039,8 +1051,9 @@ async function inspectAttributes(
|
|
|
1039
1051
|
async function executeRetrieve(
|
|
1040
1052
|
resolved: ResolvedRetrieveRequest,
|
|
1041
1053
|
backend: ProtocolAdapter,
|
|
1054
|
+
inspection: RetrieveInspection | undefined,
|
|
1042
1055
|
): Promise<unknown> {
|
|
1043
|
-
if (isKnownSingletonPath(resolved.path)) {
|
|
1056
|
+
if (inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
|
|
1044
1057
|
const data = await backend.getSingleton(resolved.path);
|
|
1045
1058
|
if (resolved.attributes.length > 0) {
|
|
1046
1059
|
return projectSingletonAttributes(data, resolved.attributes);
|
|
@@ -1054,6 +1067,7 @@ async function executeRetrieve(
|
|
|
1054
1067
|
});
|
|
1055
1068
|
}
|
|
1056
1069
|
|
|
1070
|
+
/** Fallback for `--validate=false`, which skips the inspect that detects singletons. */
|
|
1057
1071
|
function isKnownSingletonPath(path: string): boolean {
|
|
1058
1072
|
return ["/system/resource", "/system/identity"].includes(
|
|
1059
1073
|
path.replace(/\/$/, ""),
|
|
@@ -1192,62 +1206,3 @@ function exhaustiveOutputFormat(value: never): never {
|
|
|
1192
1206
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1193
1207
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1194
1208
|
}
|
|
1195
|
-
|
|
1196
|
-
export function toYaml(value: unknown, indent = 0): string {
|
|
1197
|
-
const prefix = " ".repeat(indent);
|
|
1198
|
-
if (value === null || value === undefined) {
|
|
1199
|
-
return "null";
|
|
1200
|
-
}
|
|
1201
|
-
if (typeof value === "string") {
|
|
1202
|
-
return yamlScalar(value);
|
|
1203
|
-
}
|
|
1204
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
1205
|
-
return String(value);
|
|
1206
|
-
}
|
|
1207
|
-
if (Array.isArray(value)) {
|
|
1208
|
-
if (value.length === 0) {
|
|
1209
|
-
return `${prefix}[]`;
|
|
1210
|
-
}
|
|
1211
|
-
return value
|
|
1212
|
-
.map((item) => {
|
|
1213
|
-
if (isScalar(item)) {
|
|
1214
|
-
return `${prefix}- ${toYaml(item, indent + 2)}`;
|
|
1215
|
-
}
|
|
1216
|
-
return `${prefix}-\n${toYaml(item, indent + 2)}`;
|
|
1217
|
-
})
|
|
1218
|
-
.join("\n");
|
|
1219
|
-
}
|
|
1220
|
-
if (isPlainObject(value)) {
|
|
1221
|
-
const entries = Object.entries(value).filter(
|
|
1222
|
-
([, entryValue]) => entryValue !== undefined,
|
|
1223
|
-
);
|
|
1224
|
-
if (entries.length === 0) {
|
|
1225
|
-
return `${prefix}{}`;
|
|
1226
|
-
}
|
|
1227
|
-
return entries
|
|
1228
|
-
.map(([key, entryValue]) => {
|
|
1229
|
-
if (isScalar(entryValue)) {
|
|
1230
|
-
return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
|
|
1231
|
-
}
|
|
1232
|
-
return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
|
|
1233
|
-
})
|
|
1234
|
-
.join("\n");
|
|
1235
|
-
}
|
|
1236
|
-
return yamlScalar(JSON.stringify(value));
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
function isScalar(value: unknown): value is boolean | number | string | null {
|
|
1240
|
-
return (
|
|
1241
|
-
value === null ||
|
|
1242
|
-
typeof value === "string" ||
|
|
1243
|
-
typeof value === "number" ||
|
|
1244
|
-
typeof value === "boolean"
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
function yamlScalar(value: string): string {
|
|
1249
|
-
if (value.length === 0) {
|
|
1250
|
-
return '""';
|
|
1251
|
-
}
|
|
1252
|
-
return JSON.stringify(value);
|
|
1253
|
-
}
|
package/src/settings.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
Warning,
|
|
25
25
|
} from "./core/envelope.ts";
|
|
26
26
|
import { buildTip } from "./core/envelope.ts";
|
|
27
|
+
import { toYaml } from "./core/yaml.ts";
|
|
27
28
|
import { loadCdb, resolveDevicesSettings } from "./devices.ts";
|
|
28
29
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
29
30
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
readSettingsFileRaw,
|
|
38
39
|
resolveOptionalIntegerSetting,
|
|
39
40
|
} from "./resolver/index.ts";
|
|
40
|
-
import { retrieveOutputFormats
|
|
41
|
+
import { retrieveOutputFormats } from "./retrieve.ts";
|
|
41
42
|
|
|
42
43
|
// ── Envelope shape ───────────────────────────────────────────────────────────
|
|
43
44
|
|
package/src/transfer-fanout.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
runFanout,
|
|
39
39
|
summarizeFanout,
|
|
40
40
|
} from "./core/fanout.ts";
|
|
41
|
+
import { toYaml } from "./core/yaml.ts";
|
|
41
42
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
42
43
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
43
44
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
@@ -50,7 +51,6 @@ import {
|
|
|
50
51
|
type SelectionResolveInput,
|
|
51
52
|
type TargetSelection,
|
|
52
53
|
} from "./resolver/index.ts";
|
|
53
|
-
import { toYaml } from "./retrieve.ts";
|
|
54
54
|
import {
|
|
55
55
|
buildTransferErrorEnvelope,
|
|
56
56
|
isTransferWriteVerb,
|
package/src/transfer.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
EnvelopeValidationMeta,
|
|
30
30
|
Warning,
|
|
31
31
|
} from "./core/envelope.ts";
|
|
32
|
+
import { toYaml } from "./core/yaml.ts";
|
|
32
33
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
33
34
|
import {
|
|
34
35
|
createProtocolAdapter,
|
|
@@ -54,7 +55,6 @@ import {
|
|
|
54
55
|
resolveTarget,
|
|
55
56
|
toCoreSource,
|
|
56
57
|
} from "./resolver/index.ts";
|
|
57
|
-
import { toYaml } from "./retrieve.ts";
|
|
58
58
|
|
|
59
59
|
export const transferOutputFormats = ["text", "json", "yaml"] as const;
|
|
60
60
|
export type TransferOutputFormat = (typeof transferOutputFormats)[number];
|