@tikoci/centrs 0.1.0 → 0.1.2
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 +73 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/devices/README.md +26 -0
- package/commands/discover/README.md +17 -8
- package/commands/discover/examples.md +10 -5
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- 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/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/btest.ts +45 -1
- package/src/cli/common.ts +64 -0
- package/src/cli/discover.ts +26 -0
- package/src/cli/execute.ts +30 -4
- package/src/cli/missing-target.ts +171 -0
- package/src/cli/retrieve.ts +32 -14
- package/src/cli/terminal.ts +18 -4
- package/src/cli/transfer.ts +37 -9
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/discover.ts +96 -23
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
|
@@ -0,0 +1,160 @@
|
|
|
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 } from "../errors.ts";
|
|
15
|
+
import type { RouterOsProtocol } from "../protocols/index.ts";
|
|
16
|
+
import type { FanoutSummary } from "./envelope.ts";
|
|
17
|
+
|
|
18
|
+
/** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
|
|
19
|
+
export const FANOUT_CONCURRENCY_DEFAULTS: Record<
|
|
20
|
+
"rest-api" | "native-api",
|
|
21
|
+
number
|
|
22
|
+
> = {
|
|
23
|
+
"rest-api": 8,
|
|
24
|
+
"native-api": 4,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Extra attempts after the first try (jittered backoff between them). */
|
|
28
|
+
export const FANOUT_MAX_RETRIES = 2;
|
|
29
|
+
|
|
30
|
+
/** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
|
|
31
|
+
export const FANOUT_BACKOFF_BASE_MS = 200;
|
|
32
|
+
|
|
33
|
+
/** RouterOS-style codes that are safe to retry under fan-out. */
|
|
34
|
+
export const FANOUT_RETRYABLE_CODES = [
|
|
35
|
+
"transport/network",
|
|
36
|
+
"transport/connection-closed",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/** Resolve and validate the worker-pool size. */
|
|
40
|
+
export function resolveFanoutConcurrency(
|
|
41
|
+
requested: number | undefined,
|
|
42
|
+
via: RouterOsProtocol,
|
|
43
|
+
): number {
|
|
44
|
+
if (requested !== undefined) {
|
|
45
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
46
|
+
throw new CentrsError({
|
|
47
|
+
code: "usage/invalid-concurrency",
|
|
48
|
+
summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
|
|
49
|
+
remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
|
|
50
|
+
context: { concurrency: requested },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return requested;
|
|
54
|
+
}
|
|
55
|
+
if (via === "native-api") {
|
|
56
|
+
return FANOUT_CONCURRENCY_DEFAULTS["native-api"];
|
|
57
|
+
}
|
|
58
|
+
return FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify a per-target failure as retryable. Only the locked allowlist retries:
|
|
63
|
+
* generic transport/network failures and dropped/closed connections (including
|
|
64
|
+
* REST 5xx mapped to `transport/connection-closed`). Router-side (`routeros/*`),
|
|
65
|
+
* validation, auth, cdb, target, input, usage, timeout, TLS, connection-refused,
|
|
66
|
+
* and DNS failures are deterministic and never retried.
|
|
67
|
+
*/
|
|
68
|
+
export function isRetryableFanoutError(error: unknown): boolean {
|
|
69
|
+
if (!(error instanceof CentrsError)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return (FANOUT_RETRYABLE_CODES as readonly string[]).includes(error.code);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Count inner `ok`/`failed` across a completed target set. */
|
|
76
|
+
export function summarizeFanout(
|
|
77
|
+
targets: readonly { ok: boolean }[],
|
|
78
|
+
): FanoutSummary {
|
|
79
|
+
let ok = 0;
|
|
80
|
+
let failed = 0;
|
|
81
|
+
for (const target of targets) {
|
|
82
|
+
if (target.ok) {
|
|
83
|
+
ok += 1;
|
|
84
|
+
} else {
|
|
85
|
+
failed += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { total: targets.length, ok, failed };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ordered bounded worker pool. Runs `worker` over `items` with at most
|
|
93
|
+
* `concurrency` in flight, returning results in INPUT order regardless of
|
|
94
|
+
* completion order.
|
|
95
|
+
*/
|
|
96
|
+
export async function runBoundedPool<I, O>(
|
|
97
|
+
items: readonly I[],
|
|
98
|
+
concurrency: number,
|
|
99
|
+
worker: (item: I, index: number) => Promise<O>,
|
|
100
|
+
): Promise<O[]> {
|
|
101
|
+
const results = new Array<O>(items.length);
|
|
102
|
+
let next = 0;
|
|
103
|
+
const width = Math.max(1, Math.min(concurrency, items.length));
|
|
104
|
+
|
|
105
|
+
async function drain(): Promise<void> {
|
|
106
|
+
for (;;) {
|
|
107
|
+
const index = next;
|
|
108
|
+
next += 1;
|
|
109
|
+
if (index >= items.length) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
results[index] = await worker(items[index] as I, index);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await Promise.all(Array.from({ length: width }, () => drain()));
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run `run`, retrying only {@link isRetryableFanoutError} failures up to
|
|
122
|
+
* `maxRetries` times with jittered backoff. A non-retryable error, or the final
|
|
123
|
+
* exhausted attempt, is converted to a value via `recover` (so a fan-out target
|
|
124
|
+
* yields an inner error envelope instead of throwing out of the batch).
|
|
125
|
+
*/
|
|
126
|
+
export async function runWithRetry<T>(
|
|
127
|
+
run: () => Promise<T>,
|
|
128
|
+
recover: (error: unknown) => T,
|
|
129
|
+
options: {
|
|
130
|
+
sleep?: (ms: number) => Promise<void>;
|
|
131
|
+
maxRetries?: number;
|
|
132
|
+
} = {},
|
|
133
|
+
): Promise<T> {
|
|
134
|
+
const sleep = options.sleep ?? defaultFanoutSleep;
|
|
135
|
+
const maxRetries = options.maxRetries ?? FANOUT_MAX_RETRIES;
|
|
136
|
+
let attempt = 0;
|
|
137
|
+
for (;;) {
|
|
138
|
+
try {
|
|
139
|
+
return await run();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (attempt < maxRetries && isRetryableFanoutError(error)) {
|
|
142
|
+
attempt += 1;
|
|
143
|
+
await sleep(fanoutBackoffMs(attempt));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
return recover(error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Backoff for attempt N: base·2^(N-1) plus up to +base jitter. */
|
|
152
|
+
export function fanoutBackoffMs(attempt: number): number {
|
|
153
|
+
const base = FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
154
|
+
return base + Math.floor(Math.random() * FANOUT_BACKOFF_BASE_MS);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Real timer sleep; tests inject a no-op. */
|
|
158
|
+
export function defaultFanoutSleep(ms: number): Promise<void> {
|
|
159
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
160
|
+
}
|
|
@@ -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
|
+
}
|
package/src/discover.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createSocket } from "node:dgram";
|
|
21
|
-
import type { CentrsEnvelope, EnvelopeMeta } from "./core/envelope.ts";
|
|
21
|
+
import type { CentrsEnvelope, EnvelopeMeta, Tip } from "./core/envelope.ts";
|
|
22
|
+
import { buildTip } from "./core/envelope.ts";
|
|
22
23
|
import {
|
|
23
24
|
MNDP_BROADCAST_ADDRESS,
|
|
24
25
|
MNDP_PORT,
|
|
@@ -27,7 +28,7 @@ import {
|
|
|
27
28
|
parseMndpPacket,
|
|
28
29
|
} from "./data/mndp.ts";
|
|
29
30
|
import { MndpCache } from "./data/mndp-cache.ts";
|
|
30
|
-
import { winBoxCdbRecordType } from "./data/winbox-cdb.ts";
|
|
31
|
+
import { type WinBoxCdbEntry, winBoxCdbRecordType } from "./data/winbox-cdb.ts";
|
|
31
32
|
import type { WriteWinBoxCdbOptions } from "./data/winbox-cdb-write.ts";
|
|
32
33
|
import {
|
|
33
34
|
addDevice,
|
|
@@ -37,7 +38,8 @@ import {
|
|
|
37
38
|
resolveDevicesSettings,
|
|
38
39
|
} from "./devices.ts";
|
|
39
40
|
import { asCentrsError, CentrsError, serializeCentrsError } from "./errors.ts";
|
|
40
|
-
import { renderCommentKvToken } from "./resolver/comment-kv.ts";
|
|
41
|
+
import { parseCommentKv, renderCommentKvToken } from "./resolver/comment-kv.ts";
|
|
42
|
+
import { normalizeMac } from "./resolver/mac.ts";
|
|
41
43
|
|
|
42
44
|
export { MNDP_PORT, MndpCache };
|
|
43
45
|
|
|
@@ -396,38 +398,75 @@ function toDiscoverNeighbor(
|
|
|
396
398
|
}
|
|
397
399
|
|
|
398
400
|
/**
|
|
399
|
-
* Build the
|
|
400
|
-
* the
|
|
401
|
-
*
|
|
402
|
-
*
|
|
401
|
+
* Build the comment for a saved neighbor: the allowlisted `source=mndp` token
|
|
402
|
+
* and the `identity=`/`mac=` **lookup keys** (so the device resolves by any of
|
|
403
|
+
* its identifiers — see `commands/devices/README.md`, Identity model), followed
|
|
404
|
+
* by a free-form, parenthesized provenance detail. `identity=` is written
|
|
405
|
+
* whenever advertised; `mac=` only when the **target is the IP** (a `macTarget`
|
|
406
|
+
* record's target already *is* the MAC, so a `mac=` key would be redundant). The
|
|
407
|
+
* parenthesized detail carries no `key=value` shapes, so it stays inert and
|
|
408
|
+
* never emits a `cdb/unknown-option` warning later.
|
|
403
409
|
*/
|
|
410
|
+
|
|
411
|
+
// Strip `=` from free-form device-advertised strings so they can't be
|
|
412
|
+
// misinterpreted as lookup key=value tokens if parseCommentKv ever processes
|
|
413
|
+
// the parenthesized section.
|
|
414
|
+
function sanitizeDetail(value: string): string {
|
|
415
|
+
return value.replaceAll("=", "≔");
|
|
416
|
+
}
|
|
417
|
+
|
|
404
418
|
export function formatMndpProvenanceComment(
|
|
405
419
|
neighbor: MndpNeighbor,
|
|
406
420
|
at: Date,
|
|
407
421
|
): string {
|
|
408
|
-
const
|
|
422
|
+
const tokens: string[] = [renderCommentKvToken("source", "mndp")];
|
|
409
423
|
if (neighbor.identity) {
|
|
410
|
-
|
|
424
|
+
tokens.push(renderCommentKvToken("identity", neighbor.identity));
|
|
425
|
+
}
|
|
426
|
+
if (neighbor.ipv4 && neighbor.macAddress) {
|
|
427
|
+
tokens.push(renderCommentKvToken("mac", neighbor.macAddress));
|
|
411
428
|
}
|
|
429
|
+
const detail: string[] = [`discovered ${at.toISOString()} via MNDP`];
|
|
412
430
|
if (neighbor.platform) {
|
|
413
|
-
detail.push(`platform: ${neighbor.platform}`);
|
|
431
|
+
detail.push(`platform: ${sanitizeDetail(neighbor.platform)}`);
|
|
414
432
|
}
|
|
415
433
|
if (neighbor.board) {
|
|
416
|
-
detail.push(`board: ${neighbor.board}`);
|
|
434
|
+
detail.push(`board: ${sanitizeDetail(neighbor.board)}`);
|
|
417
435
|
}
|
|
418
436
|
if (neighbor.version) {
|
|
419
|
-
detail.push(`version: ${neighbor.version}`);
|
|
420
|
-
}
|
|
421
|
-
if (neighbor.macAddress) {
|
|
422
|
-
detail.push(`mac: ${neighbor.macAddress}`);
|
|
437
|
+
detail.push(`version: ${sanitizeDetail(neighbor.version)}`);
|
|
423
438
|
}
|
|
424
439
|
if (neighbor.interfaceName) {
|
|
425
|
-
detail.push(`interface: ${neighbor.interfaceName}`);
|
|
440
|
+
detail.push(`interface: ${sanitizeDetail(neighbor.interfaceName)}`);
|
|
426
441
|
}
|
|
427
442
|
if (neighbor.softwareId) {
|
|
428
|
-
detail.push(`software-id: ${neighbor.softwareId}`);
|
|
443
|
+
detail.push(`software-id: ${sanitizeDetail(neighbor.softwareId)}`);
|
|
429
444
|
}
|
|
430
|
-
return `${
|
|
445
|
+
return `${tokens.join(" ")} (${detail.join("; ")})`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* True when a discovered neighbor's MAC already names a CDB entry — as the
|
|
450
|
+
* `target` of a `macTarget` record or as a `mac=` lookup key on any record. MNDP
|
|
451
|
+
* always carries the MAC and it is globally unique, so it is the de-dupe key
|
|
452
|
+
* (`commands/discover/README.md`, `--save` de-dupe); `identity` is *not*, since
|
|
453
|
+
* factory-default devices all report `MikroTik`.
|
|
454
|
+
*/
|
|
455
|
+
function neighborMacKnown(
|
|
456
|
+
entries: readonly WinBoxCdbEntry[],
|
|
457
|
+
mac: string | undefined,
|
|
458
|
+
): boolean {
|
|
459
|
+
const want = normalizeMac(mac ?? "");
|
|
460
|
+
if (!want) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
return entries.some((entry) => {
|
|
464
|
+
if (normalizeMac(entry.target) === want) {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
const lookupMac = parseCommentKv(entry.comment).lookups.mac;
|
|
468
|
+
return lookupMac !== undefined && normalizeMac(lookupMac) === want;
|
|
469
|
+
});
|
|
431
470
|
}
|
|
432
471
|
|
|
433
472
|
/** Target a neighbor maps to in the CDB: its IPv4, else its MAC. */
|
|
@@ -457,9 +496,12 @@ export interface SaveDiscoveredNeighborsResult {
|
|
|
457
496
|
|
|
458
497
|
/**
|
|
459
498
|
* Persist discovered neighbors into the CDB via {@link addDevice}. De-dupe
|
|
460
|
-
* rule: a neighbor whose
|
|
461
|
-
*
|
|
462
|
-
*
|
|
499
|
+
* rule: a neighbor whose **MAC** already names a CDB entry — as a `macTarget`
|
|
500
|
+
* record's `target` or a `mac=` lookup key on any record (see
|
|
501
|
+
* {@link neighborMacKnown}) — is skipped, never overwritten; a neighbor whose
|
|
502
|
+
* `target` string already names an entry is likewise skipped. Hand-curated
|
|
503
|
+
* records always win. New neighbors are added with `group=discovered`, the
|
|
504
|
+
* `identity=`/`mac=` lookup keys, and `source=mndp` provenance. The CDB is
|
|
463
505
|
* reloaded between writes so each `addDevice` sees the prior additions.
|
|
464
506
|
*/
|
|
465
507
|
export async function saveDiscoveredNeighbors(
|
|
@@ -484,7 +526,13 @@ export async function saveDiscoveredNeighbors(
|
|
|
484
526
|
if (!target) {
|
|
485
527
|
continue;
|
|
486
528
|
}
|
|
487
|
-
|
|
529
|
+
// De-dupe on the MAC (globally unique, always advertised), and also skip a
|
|
530
|
+
// target string that already names an entry — both guard a hand-curated
|
|
531
|
+
// record from being clobbered, and the latter keeps `addDevice` from
|
|
532
|
+
// failing `cdb/already-exists` on a colliding connectable address.
|
|
533
|
+
const exists =
|
|
534
|
+
neighborMacKnown(cdb.entries, neighbor.macAddress) ||
|
|
535
|
+
cdb.entries.some((entry) => entry.target === target);
|
|
488
536
|
const detail: DiscoverSaveRecord = {
|
|
489
537
|
target,
|
|
490
538
|
action: exists ? "skipped-existing" : "added",
|
|
@@ -646,11 +694,22 @@ export async function discover(
|
|
|
646
694
|
}
|
|
647
695
|
}
|
|
648
696
|
|
|
697
|
+
const tips: Tip[] = [];
|
|
698
|
+
if (!options.save && neighbors.length > 0) {
|
|
699
|
+
tips.push(
|
|
700
|
+
buildTip(
|
|
701
|
+
"tip/discover-save",
|
|
702
|
+
`Found ${neighbors.length} neighbor(s); this run did not save them (read-only without --save).`,
|
|
703
|
+
"Re-run with `--save` to persist them into the CDB (group=discovered) so you can target them by identity/ip/mac.",
|
|
704
|
+
),
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
649
708
|
return {
|
|
650
709
|
ok: true,
|
|
651
710
|
data: { count: neighbors.length, neighbors },
|
|
652
711
|
warnings,
|
|
653
|
-
tips
|
|
712
|
+
tips,
|
|
654
713
|
meta: discoverMeta(operation, options),
|
|
655
714
|
};
|
|
656
715
|
}
|
|
@@ -743,9 +802,23 @@ function renderDiscoverText(envelope: DiscoverEnvelope): string {
|
|
|
743
802
|
);
|
|
744
803
|
}
|
|
745
804
|
appendDiscoverWarnings(lines, envelope.warnings);
|
|
805
|
+
appendDiscoverTips(lines, envelope.tips);
|
|
746
806
|
return lines.join("\n");
|
|
747
807
|
}
|
|
748
808
|
|
|
809
|
+
function appendDiscoverTips(lines: string[], tips: readonly Tip[]): void {
|
|
810
|
+
if (tips.length === 0) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
lines.push("Tips:");
|
|
814
|
+
for (const item of tips) {
|
|
815
|
+
lines.push(` - [${item.code}] ${item.message}`);
|
|
816
|
+
if (item.fix) {
|
|
817
|
+
lines.push(` fix: ${item.fix}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
749
822
|
function appendDiscoverWarnings(
|
|
750
823
|
lines: string[],
|
|
751
824
|
warnings: readonly DevicesWarning[],
|
package/src/execute.ts
CHANGED
|
@@ -9,6 +9,13 @@ import type {
|
|
|
9
9
|
Warning,
|
|
10
10
|
} from "./core/envelope.ts";
|
|
11
11
|
import { buildTip } from "./core/envelope.ts";
|
|
12
|
+
import {
|
|
13
|
+
extractCompletionNames,
|
|
14
|
+
inspectChildren,
|
|
15
|
+
inspectCompletions,
|
|
16
|
+
isArgumentNode,
|
|
17
|
+
pathTokens,
|
|
18
|
+
} from "./core/inspect.ts";
|
|
12
19
|
import { mapRouterOsError } from "./core/routeros-errors.ts";
|
|
13
20
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
14
21
|
import {
|
|
@@ -132,20 +139,6 @@ export interface ResolvedExecuteRequest {
|
|
|
132
139
|
warnings: readonly Warning[];
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
interface InspectChildItem {
|
|
136
|
-
type?: string;
|
|
137
|
-
name?: string;
|
|
138
|
-
"node-type"?: string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface InspectCompletionItem {
|
|
142
|
-
type?: string;
|
|
143
|
-
name?: string;
|
|
144
|
-
completion?: string;
|
|
145
|
-
value?: string;
|
|
146
|
-
text?: string;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
142
|
export async function execute(
|
|
150
143
|
request: ExecuteRequest,
|
|
151
144
|
env: Record<string, string | undefined> = Bun.env,
|
|
@@ -735,24 +728,18 @@ async function inspectExecuteAttributes(
|
|
|
735
728
|
resolved: ResolvedExecuteRequest,
|
|
736
729
|
backend: ProtocolAdapter,
|
|
737
730
|
): Promise<string[]> {
|
|
738
|
-
const
|
|
731
|
+
const commandTokens = [
|
|
739
732
|
...pathTokens(resolved.canonical.path),
|
|
740
733
|
resolved.canonical.verb,
|
|
741
|
-
]
|
|
742
|
-
const children =
|
|
743
|
-
"child",
|
|
744
|
-
commandPath,
|
|
745
|
-
)) as InspectChildItem[];
|
|
734
|
+
];
|
|
735
|
+
const children = await inspectChildren(backend, commandTokens);
|
|
746
736
|
const childAttributes = children
|
|
747
737
|
.filter(isArgumentNode)
|
|
748
738
|
.map((child) => child.name)
|
|
749
739
|
.filter(
|
|
750
740
|
(name): name is string => typeof name === "string" && name.length > 0,
|
|
751
741
|
);
|
|
752
|
-
const completionRows =
|
|
753
|
-
"completion",
|
|
754
|
-
commandPath,
|
|
755
|
-
)) as InspectCompletionItem[];
|
|
742
|
+
const completionRows = await inspectCompletions(backend, commandTokens);
|
|
756
743
|
return [
|
|
757
744
|
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
758
745
|
].sort();
|
|
@@ -819,7 +806,9 @@ async function assertWriteConfirmed(
|
|
|
819
806
|
});
|
|
820
807
|
}
|
|
821
808
|
|
|
822
|
-
async function promptForWriteConfirmation(
|
|
809
|
+
export async function promptForWriteConfirmation(
|
|
810
|
+
prompt: string,
|
|
811
|
+
): Promise<boolean> {
|
|
823
812
|
process.stderr.write(prompt);
|
|
824
813
|
for await (const chunk of Bun.stdin.stream()) {
|
|
825
814
|
const answer = new TextDecoder().decode(chunk).trim().toLowerCase();
|
|
@@ -1299,28 +1288,6 @@ function syntaxCause(error: unknown): unknown {
|
|
|
1299
1288
|
return error;
|
|
1300
1289
|
}
|
|
1301
1290
|
|
|
1302
|
-
function pathTokens(path: string): string[] {
|
|
1303
|
-
return path.split("/").filter(Boolean);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
function inspectPath(tokens: readonly string[]): string {
|
|
1307
|
-
return tokens.join(",");
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function isArgumentNode(child: InspectChildItem): boolean {
|
|
1311
|
-
return child.type === "arg" || child["node-type"] === "arg";
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function extractCompletionNames(
|
|
1315
|
-
rows: readonly InspectCompletionItem[],
|
|
1316
|
-
): string[] {
|
|
1317
|
-
return rows
|
|
1318
|
-
.flatMap((row) => [row.completion, row.name, row.value, row.text])
|
|
1319
|
-
.filter((value): value is string => typeof value === "string")
|
|
1320
|
-
.map((value) => value.replace(/=.*$/, "").trim())
|
|
1321
|
-
.filter((value) => value.length > 0);
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
1291
|
function parseOutputFormat(value: string): ExecuteOutputFormat {
|
|
1325
1292
|
if (executeOutputFormats.includes(value as ExecuteOutputFormat)) {
|
|
1326
1293
|
return value as ExecuteOutputFormat;
|