@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +73 -2
  2. package/GLOSSARY.txt +16 -0
  3. package/README.md +1 -1
  4. package/commands/AGENTS.md +3 -2
  5. package/commands/api/AGENTS.md +212 -0
  6. package/commands/api/README.md +159 -0
  7. package/commands/api/examples.md +325 -0
  8. package/commands/btest/README.md +56 -31
  9. package/commands/btest/examples.md +35 -10
  10. package/commands/devices/README.md +26 -0
  11. package/commands/discover/README.md +17 -8
  12. package/commands/discover/examples.md +10 -5
  13. package/commands/execute/README.md +9 -3
  14. package/commands/retrieve/README.md +1 -1
  15. package/docs/CONSTITUTION.md +6 -6
  16. package/docs/MATRIX.md +46 -15
  17. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  18. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  19. package/docs/errors/usage/invalid-method.md +23 -0
  20. package/package.json +5 -5
  21. package/src/api.ts +1555 -0
  22. package/src/btest.ts +61 -1
  23. package/src/cli/api.ts +536 -0
  24. package/src/cli/btest.ts +45 -1
  25. package/src/cli/common.ts +64 -0
  26. package/src/cli/discover.ts +26 -0
  27. package/src/cli/execute.ts +30 -4
  28. package/src/cli/missing-target.ts +171 -0
  29. package/src/cli/retrieve.ts +32 -14
  30. package/src/cli/terminal.ts +18 -4
  31. package/src/cli/transfer.ts +37 -9
  32. package/src/cli.ts +22 -0
  33. package/src/core/error-catalog.ts +14 -0
  34. package/src/core/fanout.ts +160 -0
  35. package/src/core/inspect.ts +170 -0
  36. package/src/discover.ts +96 -23
  37. package/src/execute.ts +14 -47
  38. package/src/index.ts +61 -8
  39. package/src/protocols/adapter.ts +328 -0
  40. package/src/protocols/btest-session.ts +279 -46
  41. package/src/protocols/btest.ts +6 -2
  42. package/src/protocols/native-api.ts +223 -30
  43. package/src/retrieve-fanout.ts +13 -141
  44. package/src/retrieve.ts +30 -87
  45. 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 human/structured comment for a saved neighbor. The only kv token is
400
- * the allowlisted `source=mndp`; the parenthesized detail is free-form (no
401
- * `key=value` shapes) so it preserves provenance without emitting
402
- * `cdb/unknown-option` warnings later.
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 detail: string[] = [`discovered ${at.toISOString()} via MNDP`];
422
+ const tokens: string[] = [renderCommentKvToken("source", "mndp")];
409
423
  if (neighbor.identity) {
410
- detail.push(`identity: ${neighbor.identity}`);
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 `${renderCommentKvToken("source", "mndp")} (${detail.join("; ")})`;
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 target (IPv4, else MAC) already names a CDB entry is
461
- * skipped — never overwritten — so hand-curated records win. New neighbors are
462
- * added with `group=discovered` and `source=mndp` provenance. The CDB is
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
- const exists = cdb.entries.some((entry) => entry.target === target);
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 commandPath = inspectPath([
731
+ const commandTokens = [
739
732
  ...pathTokens(resolved.canonical.path),
740
733
  resolved.canonical.verb,
741
- ]);
742
- const children = (await backend.inspect(
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 = (await backend.inspect(
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(prompt: string): Promise<boolean> {
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;