@tikoci/centrs 0.1.1 → 0.1.3
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 +107 -2
- package/GLOSSARY.txt +87 -0
- package/README.md +25 -9
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +177 -0
- package/commands/api/examples.md +428 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +48 -7
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +37 -51
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +40 -15
- package/docs/MATRIX.md +69 -142
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +9 -9
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +1627 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +660 -0
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +75 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +29 -0
- package/src/core/error-catalog.ts +69 -0
- package/src/core/fanout.ts +301 -0
- package/src/core/inspect.ts +170 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +134 -70
- package/src/index.ts +166 -9
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +357 -9
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/index.ts +11 -5
- package/src/protocols/native-api.ts +223 -30
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +232 -268
- package/src/retrieve.ts +69 -94
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
- package/commands/stream/README.md +0 -70
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic fan-out engine.
|
|
3
|
+
*
|
|
4
|
+
* `retrieve` group fan-out (`src/retrieve-fanout.ts`) grew the reusable
|
|
5
|
+
* machinery: a bounded worker pool, transport-aware concurrency defaults, a
|
|
6
|
+
* retry allowlist with jittered backoff, and the ok/failed summary. `api` — and
|
|
7
|
+
* any later fan-out command — needs the identical behavior, so it lives here as
|
|
8
|
+
* one vocabulary. The command-specific orchestration (CDB group expansion,
|
|
9
|
+
* per-target resolve, envelope shaping, text rendering) stays in each command's
|
|
10
|
+
* fan-out module; this core only knows about generic items, concurrency, retry,
|
|
11
|
+
* and counting.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CentrsError, serializeCentrsError } from "../errors.ts";
|
|
15
|
+
import type { RouterOsProtocol } from "../protocols/index.ts";
|
|
16
|
+
import type {
|
|
17
|
+
CentrsEnvelope,
|
|
18
|
+
CentrsErrorEnvelope,
|
|
19
|
+
CentrsSuccessEnvelope,
|
|
20
|
+
CommonSettingsMeta,
|
|
21
|
+
EnvelopeTargetMeta,
|
|
22
|
+
FanoutData,
|
|
23
|
+
FanoutSummary,
|
|
24
|
+
Warning,
|
|
25
|
+
} from "./envelope.ts";
|
|
26
|
+
|
|
27
|
+
/** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
|
|
28
|
+
export const FANOUT_CONCURRENCY_DEFAULTS: Record<
|
|
29
|
+
"rest-api" | "native-api",
|
|
30
|
+
number
|
|
31
|
+
> = {
|
|
32
|
+
"rest-api": 8,
|
|
33
|
+
"native-api": 4,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Extra attempts after the first try (jittered backoff between them). */
|
|
37
|
+
export const FANOUT_MAX_RETRIES = 2;
|
|
38
|
+
|
|
39
|
+
/** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
|
|
40
|
+
export const FANOUT_BACKOFF_BASE_MS = 200;
|
|
41
|
+
|
|
42
|
+
/** RouterOS-style codes that are safe to retry under fan-out. */
|
|
43
|
+
export const FANOUT_RETRYABLE_CODES = [
|
|
44
|
+
"transport/network",
|
|
45
|
+
"transport/connection-closed",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/** Resolve and validate the worker-pool size. */
|
|
49
|
+
export function resolveFanoutConcurrency(
|
|
50
|
+
requested: number | undefined,
|
|
51
|
+
via: RouterOsProtocol,
|
|
52
|
+
): number {
|
|
53
|
+
if (requested !== undefined) {
|
|
54
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
55
|
+
throw new CentrsError({
|
|
56
|
+
code: "usage/invalid-concurrency",
|
|
57
|
+
summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
|
|
58
|
+
remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
|
|
59
|
+
context: { concurrency: requested },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return requested;
|
|
63
|
+
}
|
|
64
|
+
if (via === "native-api") {
|
|
65
|
+
return FANOUT_CONCURRENCY_DEFAULTS["native-api"];
|
|
66
|
+
}
|
|
67
|
+
return FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Classify a per-target failure as retryable. Only the locked allowlist retries:
|
|
72
|
+
* generic transport/network failures and dropped/closed connections (including
|
|
73
|
+
* REST 5xx mapped to `transport/connection-closed`). Router-side (`routeros/*`),
|
|
74
|
+
* validation, auth, cdb, target, input, usage, timeout, TLS, connection-refused,
|
|
75
|
+
* and DNS failures are deterministic and never retried.
|
|
76
|
+
*/
|
|
77
|
+
export function isRetryableFanoutError(error: unknown): boolean {
|
|
78
|
+
if (!(error instanceof CentrsError)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return (FANOUT_RETRYABLE_CODES as readonly string[]).includes(error.code);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Count inner `ok`/`failed` across a completed target set. */
|
|
85
|
+
export function summarizeFanout(
|
|
86
|
+
targets: readonly { ok: boolean }[],
|
|
87
|
+
): FanoutSummary {
|
|
88
|
+
let ok = 0;
|
|
89
|
+
let failed = 0;
|
|
90
|
+
for (const target of targets) {
|
|
91
|
+
if (target.ok) {
|
|
92
|
+
ok += 1;
|
|
93
|
+
} else {
|
|
94
|
+
failed += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { total: targets.length, ok, failed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Ordered bounded worker pool. Runs `worker` over `items` with at most
|
|
102
|
+
* `concurrency` in flight, returning results in INPUT order regardless of
|
|
103
|
+
* completion order.
|
|
104
|
+
*/
|
|
105
|
+
export async function runBoundedPool<I, O>(
|
|
106
|
+
items: readonly I[],
|
|
107
|
+
concurrency: number,
|
|
108
|
+
worker: (item: I, index: number) => Promise<O>,
|
|
109
|
+
): Promise<O[]> {
|
|
110
|
+
const results = new Array<O>(items.length);
|
|
111
|
+
let next = 0;
|
|
112
|
+
const width = Math.max(1, Math.min(concurrency, items.length));
|
|
113
|
+
|
|
114
|
+
async function drain(): Promise<void> {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const index = next;
|
|
117
|
+
next += 1;
|
|
118
|
+
if (index >= items.length) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
results[index] = await worker(items[index] as I, index);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await Promise.all(Array.from({ length: width }, () => drain()));
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run `run`, retrying only {@link isRetryableFanoutError} failures up to
|
|
131
|
+
* `maxRetries` times with jittered backoff. A non-retryable error, or the final
|
|
132
|
+
* exhausted attempt, is converted to a value via `recover` (so a fan-out target
|
|
133
|
+
* yields an inner error envelope instead of throwing out of the batch).
|
|
134
|
+
*/
|
|
135
|
+
export async function runWithRetry<T>(
|
|
136
|
+
run: () => Promise<T>,
|
|
137
|
+
recover: (error: unknown) => T,
|
|
138
|
+
options: {
|
|
139
|
+
sleep?: (ms: number) => Promise<void>;
|
|
140
|
+
maxRetries?: number;
|
|
141
|
+
} = {},
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
const sleep = options.sleep ?? defaultFanoutSleep;
|
|
144
|
+
const maxRetries = options.maxRetries ?? FANOUT_MAX_RETRIES;
|
|
145
|
+
let attempt = 0;
|
|
146
|
+
for (;;) {
|
|
147
|
+
try {
|
|
148
|
+
return await run();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (attempt < maxRetries && isRetryableFanoutError(error)) {
|
|
151
|
+
attempt += 1;
|
|
152
|
+
await sleep(fanoutBackoffMs(attempt));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
return recover(error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Backoff for attempt N: base·2^(N-1) plus up to +base jitter. */
|
|
161
|
+
export function fanoutBackoffMs(attempt: number): number {
|
|
162
|
+
const base = FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
163
|
+
return base + Math.floor(Math.random() * FANOUT_BACKOFF_BASE_MS);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Real timer sleep; tests inject a no-op. */
|
|
167
|
+
export function defaultFanoutSleep(ms: number): Promise<void> {
|
|
168
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The per-command fan-out loop, generic over the selection member, the resolved
|
|
173
|
+
* per-target plan, and the inner envelope type. Mirrors the original
|
|
174
|
+
* `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
|
|
175
|
+
* an inner error envelope, never a throw out of the batch); run the resolved
|
|
176
|
+
* request through {@link runWithRetry} (a retryable transient drop is retried,
|
|
177
|
+
* everything else recovers to an inner error envelope). Results are returned in
|
|
178
|
+
* input (record-index) order via {@link runBoundedPool}.
|
|
179
|
+
*/
|
|
180
|
+
export async function runFanout<Member, Resolved, Env>(opts: {
|
|
181
|
+
members: readonly Member[];
|
|
182
|
+
concurrency: number;
|
|
183
|
+
resolve: (member: Member) => Promise<Resolved> | Resolved;
|
|
184
|
+
onResolveError: (member: Member, error: unknown) => Env;
|
|
185
|
+
execute: (resolved: Resolved, member: Member) => Promise<Env>;
|
|
186
|
+
onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
|
|
187
|
+
sleep?: (ms: number) => Promise<void>;
|
|
188
|
+
}): Promise<Env[]> {
|
|
189
|
+
return runBoundedPool(opts.members, opts.concurrency, async (member) => {
|
|
190
|
+
let resolved: Resolved;
|
|
191
|
+
try {
|
|
192
|
+
resolved = await opts.resolve(member);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return opts.onResolveError(member, error);
|
|
195
|
+
}
|
|
196
|
+
return runWithRetry<Env>(
|
|
197
|
+
() => opts.execute(resolved, member),
|
|
198
|
+
(error) => opts.onExecuteError(resolved, member, error),
|
|
199
|
+
{ sleep: opts.sleep },
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The uniform fan-out process exit code (granular 0/2/1; see
|
|
206
|
+
* `docs/CONSTITUTION.md`, Target selection). `0` = every target ok (or an empty
|
|
207
|
+
* selection); `2` = partial (some ok, some failed); `1` = orchestration error
|
|
208
|
+
* (outer `ok: false`) OR every target failed. Decoupled from the envelope's
|
|
209
|
+
* outer `ok`, which only reports orchestration success.
|
|
210
|
+
*/
|
|
211
|
+
export function fanoutExitCode(envelope: {
|
|
212
|
+
ok: boolean;
|
|
213
|
+
data?: { summary?: FanoutSummary };
|
|
214
|
+
}): 0 | 1 | 2 {
|
|
215
|
+
if (!envelope.ok) {
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
const summary = envelope.data?.summary;
|
|
219
|
+
if (!summary || summary.total === 0 || summary.failed === 0) {
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (summary.ok === 0) {
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Collapse per-target `via` to one value, or `null` when targets disagree. */
|
|
229
|
+
export function commonVia(
|
|
230
|
+
targets: readonly { meta: { via: RouterOsProtocol | null } }[],
|
|
231
|
+
): RouterOsProtocol | null {
|
|
232
|
+
let via: RouterOsProtocol | null | undefined;
|
|
233
|
+
for (const target of targets) {
|
|
234
|
+
const candidate = target.meta.via;
|
|
235
|
+
if (via === undefined) {
|
|
236
|
+
via = candidate;
|
|
237
|
+
} else if (via !== candidate) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return via ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Assemble the locked fan-out success envelope: `data = { summary, targets }`,
|
|
246
|
+
* outer `ok: true` = orchestration succeeded. The command owns its outer
|
|
247
|
+
* `operation` meta shape and passes it in.
|
|
248
|
+
*/
|
|
249
|
+
export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
|
|
250
|
+
summary: FanoutSummary;
|
|
251
|
+
targets: readonly CentrsEnvelope<Data, Op>[];
|
|
252
|
+
warnings: readonly Warning[];
|
|
253
|
+
settings: CommonSettingsMeta;
|
|
254
|
+
via: RouterOsProtocol | null;
|
|
255
|
+
operation: OuterOp;
|
|
256
|
+
}): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
data: { summary: input.summary, targets: input.targets },
|
|
260
|
+
warnings: input.warnings,
|
|
261
|
+
tips: [],
|
|
262
|
+
meta: {
|
|
263
|
+
target: {},
|
|
264
|
+
via: input.via,
|
|
265
|
+
settings: input.settings,
|
|
266
|
+
operation: input.operation,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build an INNER per-target error envelope for a member that failed to resolve
|
|
273
|
+
* (before any transport attempt). Carries the member's target meta so the
|
|
274
|
+
* fan-out output identifies which device failed.
|
|
275
|
+
*/
|
|
276
|
+
export function buildFanoutResolveFailure<Op = unknown>(input: {
|
|
277
|
+
error: unknown;
|
|
278
|
+
target: EnvelopeTargetMeta;
|
|
279
|
+
warnings?: readonly Warning[];
|
|
280
|
+
summary?: string;
|
|
281
|
+
}): CentrsErrorEnvelope<Op> {
|
|
282
|
+
const centrsError =
|
|
283
|
+
input.error instanceof CentrsError
|
|
284
|
+
? input.error
|
|
285
|
+
: new CentrsError({
|
|
286
|
+
code: "internal/unhandled",
|
|
287
|
+
summary: input.summary ?? "Failed to resolve a fanout target.",
|
|
288
|
+
cause: input.error,
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
error: serializeCentrsError(centrsError),
|
|
293
|
+
warnings: input.warnings ?? [],
|
|
294
|
+
tips: [],
|
|
295
|
+
meta: {
|
|
296
|
+
target: input.target,
|
|
297
|
+
via: null,
|
|
298
|
+
settings: {},
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `/console/inspect` client primitives.
|
|
3
|
+
*
|
|
4
|
+
* `retrieve` and `execute` each grew their own private copy of these helpers
|
|
5
|
+
* (path tokenizing, the comma-path join, node-type predicates, completion-name
|
|
6
|
+
* extraction). This module is the single home so a third consumer — `api`, and a
|
|
7
|
+
* future `explain`/`check` — reuses one grounded implementation instead of
|
|
8
|
+
* forking a fourth. It deliberately holds only the transport-agnostic
|
|
9
|
+
* *primitives*; each command composes its own discovery strategy on top (their
|
|
10
|
+
* strategies differ: `retrieve` probes print/get support, `execute` unions
|
|
11
|
+
* child-args with completions), so the strategy stays in the command.
|
|
12
|
+
*
|
|
13
|
+
* Grounding: the request modes mirror `tikoci/lsp-routeros-ts`
|
|
14
|
+
* (`server/src/routeros.ts` `InspectRequest`). The behavioral facts (the array-
|
|
15
|
+
* typed `path`, completion shapes) are confirmed on CHR 7.23.1 in
|
|
16
|
+
* `commands/api/AGENTS.md` — read that before changing wire behavior here.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CentrsError } from "../errors.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The transport seam this module needs. A {@link ProtocolAdapter} satisfies it
|
|
23
|
+
* structurally; depending on the narrow shape keeps `core/` decoupled from the
|
|
24
|
+
* protocol layer (no import cycle).
|
|
25
|
+
*/
|
|
26
|
+
export interface InspectBackend {
|
|
27
|
+
inspect(request: "child" | "completion", path: string): Promise<unknown[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
|
|
32
|
+
* and `completion` are wired today (the two the {@link InspectBackend} seam
|
|
33
|
+
* exposes); `highlight` (token-level error detection) and `syntax` (parse tree),
|
|
34
|
+
* plus the `input`/`.query`/`.proplist` request fields, are the documented
|
|
35
|
+
* Phase-2 extension point for value-level validation — widen the adapter seam
|
|
36
|
+
* when adding them.
|
|
37
|
+
*/
|
|
38
|
+
export type InspectRequestKind =
|
|
39
|
+
| "child"
|
|
40
|
+
| "completion"
|
|
41
|
+
| "highlight"
|
|
42
|
+
| "syntax";
|
|
43
|
+
|
|
44
|
+
/** A row from `request=child`: a menu/argument node under a path. */
|
|
45
|
+
export interface InspectChildItem {
|
|
46
|
+
type?: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
"node-type"?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A row from `request=completion`: a candidate token for a path/argument. */
|
|
52
|
+
export interface InspectCompletionItem {
|
|
53
|
+
type?: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
completion?: string;
|
|
56
|
+
value?: string;
|
|
57
|
+
text?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Split a slash RouterOS path into menu tokens, dropping the leading slash and
|
|
62
|
+
* empties: `"/ip/address"` → `["ip", "address"]`.
|
|
63
|
+
*/
|
|
64
|
+
export function pathTokens(path: string): string[] {
|
|
65
|
+
return path.split("/").filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Join menu tokens into the COMMA form `/console/inspect` expects for its `path`
|
|
70
|
+
* argument: `["ip", "address"]` → `"ip,address"`.
|
|
71
|
+
*
|
|
72
|
+
* The inspect `path` argument is internally a RouterOS *array*: a comma string
|
|
73
|
+
* is `:toarray`-split into the menu-walk tokens, whereas a `/`-prefixed command
|
|
74
|
+
* string is not split and matches no menu (confirmed on CHR 7.23.1 — slash forms
|
|
75
|
+
* return nothing; see `commands/api/AGENTS.md`). So callers must pass tokens
|
|
76
|
+
* joined by comma, never a slash command.
|
|
77
|
+
*/
|
|
78
|
+
export function inspectPath(tokens: readonly string[]): string {
|
|
79
|
+
return tokens.join(",");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True for an argument (`arg`) node from `request=child`. */
|
|
83
|
+
export function isArgumentNode(child: InspectChildItem): boolean {
|
|
84
|
+
return child.type === "arg" || child["node-type"] === "arg";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True for a command (`cmd`) node with the given name (e.g. `print`, `get`). */
|
|
88
|
+
export function isCommandNode(child: InspectChildItem, name: string): boolean {
|
|
89
|
+
return (
|
|
90
|
+
child.name === name &&
|
|
91
|
+
(child.type === "cmd" || child["node-type"] === "cmd")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Flatten completion rows into attribute names. Reads every name-like field a
|
|
97
|
+
* RouterOS build might populate (`completion`/`name`/`value`/`text`), strips
|
|
98
|
+
* everything from the first `=` onward (the `name=value` completion form), and
|
|
99
|
+
* drops blanks. Returns the names in row order **without** de-duplication or
|
|
100
|
+
* sorting — callers that need a stable set wrap with `[...new Set(names)].sort()`
|
|
101
|
+
* (both current callers do).
|
|
102
|
+
*/
|
|
103
|
+
export function extractCompletionNames(
|
|
104
|
+
rows: readonly InspectCompletionItem[],
|
|
105
|
+
): string[] {
|
|
106
|
+
return rows
|
|
107
|
+
.flatMap((row) => [row.completion, row.name, row.value, row.text])
|
|
108
|
+
.filter((value): value is string => typeof value === "string")
|
|
109
|
+
.map((value) => nameBeforeEquals(value).trim())
|
|
110
|
+
.filter((value) => value.length > 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The token before the first `=` in a RouterOS completion row. Plain string
|
|
115
|
+
* slicing (no regex) — `value.replace(/=.*$/, "")` triggers a CodeQL
|
|
116
|
+
* polynomial-ReDoS flag on this exported function's caller-supplied input.
|
|
117
|
+
*/
|
|
118
|
+
function nameBeforeEquals(value: string): string {
|
|
119
|
+
const eq = value.indexOf("=");
|
|
120
|
+
return eq === -1 ? value : value.slice(0, eq);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `request=child` for a token path. */
|
|
124
|
+
export async function inspectChildren(
|
|
125
|
+
backend: InspectBackend,
|
|
126
|
+
tokens: readonly string[],
|
|
127
|
+
): Promise<InspectChildItem[]> {
|
|
128
|
+
return (await backend.inspect(
|
|
129
|
+
"child",
|
|
130
|
+
inspectPath(tokens),
|
|
131
|
+
)) as InspectChildItem[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `request=completion` for a token path. */
|
|
135
|
+
export async function inspectCompletions(
|
|
136
|
+
backend: InspectBackend,
|
|
137
|
+
tokens: readonly string[],
|
|
138
|
+
): Promise<InspectCompletionItem[]> {
|
|
139
|
+
return (await backend.inspect(
|
|
140
|
+
"completion",
|
|
141
|
+
inspectPath(tokens),
|
|
142
|
+
)) as InspectCompletionItem[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* {@link inspectChildren} that swallows a "path not found" trap to an empty
|
|
147
|
+
* list, so the caller can classify the absence itself (REST returns an empty
|
|
148
|
+
* child list; native traps). Only the two grounded not-found codes are
|
|
149
|
+
* swallowed — `routeros/unknown-path` (the classified form) and the legacy
|
|
150
|
+
* `routeros/api-trap` catch-all; every other error propagates. Use this for the
|
|
151
|
+
* existence probe, not for attribute/value discovery (where a trap is a real
|
|
152
|
+
* failure).
|
|
153
|
+
*/
|
|
154
|
+
export async function inspectChildrenOrEmpty(
|
|
155
|
+
backend: InspectBackend,
|
|
156
|
+
tokens: readonly string[],
|
|
157
|
+
): Promise<InspectChildItem[]> {
|
|
158
|
+
try {
|
|
159
|
+
return await inspectChildren(backend, tokens);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (
|
|
162
|
+
error instanceof CentrsError &&
|
|
163
|
+
(error.code === "routeros/api-trap" ||
|
|
164
|
+
error.code === "routeros/unknown-path")
|
|
165
|
+
) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|