@tikoci/centrs 0.1.0 → 0.1.1
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 +25 -0
- package/commands/devices/README.md +26 -0
- package/commands/discover/README.md +17 -8
- package/commands/discover/examples.md +10 -5
- package/package.json +1 -1
- package/src/cli/btest.ts +45 -1
- 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/discover.ts +96 -23
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,31 @@ documenting cross-cutting shifts that affect contributors and consumers.
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.1.1 — 2026-06-22
|
|
12
|
+
|
|
13
|
+
Patch release. `@tikoci/centrs@0.1.1`.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **CI is now faster on PRs.** Cross-platform unit tests (macOS + Windows) moved
|
|
18
|
+
from `ci.yaml` to `qa.yaml`, where they run in parallel with the CHR matrix on
|
|
19
|
+
every push to `main`. PRs no longer wait for macOS/Windows runners; escapes are
|
|
20
|
+
caught on the merge-to-main QA run instead.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **CLI: registry-aware `missing-router` tips.** All router-taking commands now
|
|
25
|
+
emit a CDB-aware tip when no `<router>` argument is supplied — listing saved
|
|
26
|
+
handles from the CDB (`tip/select-target`) or pointing to `centrs discover
|
|
27
|
+
--save` when the registry is empty (`tip/no-devices`).
|
|
28
|
+
- **`discover --save` lookup keys and progress.** The saved device record now
|
|
29
|
+
includes `identity=` and `mac=` keys so downstream commands can resolve the
|
|
30
|
+
entry; MAC addresses are de-duplicated. TTY progress is shown during a scan and
|
|
31
|
+
a `--save` tip is printed at the end of a bare discover run.
|
|
32
|
+
- **CodeQL / security hardening (PR #80 review).** Addressed reviewer findings:
|
|
33
|
+
clear-text credential log suppressed; progress output switched to stderr/JSON-safe
|
|
34
|
+
path; `retrieve` resource leak closed; doc corrections.
|
|
35
|
+
|
|
11
36
|
## 0.1.0 — 2026-06-22
|
|
12
37
|
|
|
13
38
|
First published pre-release (odd minor → npm `next`). `@tikoci/centrs@0.1.0`.
|
|
@@ -403,3 +403,29 @@ When an item ships with CHR examples, fold it into the matrix and delete the row
|
|
|
403
403
|
same envelope as the CDB. Provenance source label: `dude`.
|
|
404
404
|
- The `centrs check` command will reuse the resolver and add a network probe;
|
|
405
405
|
`devices` itself stays RouterOS-IO-free.
|
|
406
|
+
|
|
407
|
+
### Missing-target guidance (and the MCP/proxy follow-up)
|
|
408
|
+
|
|
409
|
+
When a command that takes a `<router>` is called without one, the **CLI** turns
|
|
410
|
+
the bare usage error into a registry-aware tip (shared helper
|
|
411
|
+
`src/cli/missing-target.ts`): it reads the CDB best-effort and emits either
|
|
412
|
+
`tip/select-target` (lists a few saved handles to pass — identity over target) or
|
|
413
|
+
`tip/no-devices` (→ `centrs discover --save`) when the registry is empty. The
|
|
414
|
+
read is unencrypted-only and the CDB **password never threads into the tip path**,
|
|
415
|
+
so an encrypted registry falls back to generic guidance.
|
|
416
|
+
|
|
417
|
+
This guidance is currently **CLI-shaped on purpose** and is *not* yet wired into
|
|
418
|
+
the MCP/proxy frontends — that is deferred spec/review work. Two boundaries
|
|
419
|
+
should shape it when it lands, because the registry's role differs per frontend:
|
|
420
|
+
|
|
421
|
+
- The `tip/*` envelope channel is shared-core, but the specific remediation
|
|
422
|
+
*strings* (`centrs devices list`, `centrs discover --save`) are CLI verbs and
|
|
423
|
+
must **not** be emitted verbatim by MCP/proxy. Each frontend phrases the
|
|
424
|
+
next-step in its own vocabulary.
|
|
425
|
+
- On **MCP**, targets come from the **CDB allowlist**, not free-text `<router>`
|
|
426
|
+
(constitution: MCP surface). An unregistered/empty target is
|
|
427
|
+
`cdb/target-not-registered`, and the `centrs://devices` resource already
|
|
428
|
+
enumerates the allowlisted targets — so the MCP equivalent of "what can I
|
|
429
|
+
pass?" is "read the `centrs://devices` resource / register a device first,"
|
|
430
|
+
not "run discover." The **proxy** fronts the same CDB and should mirror the MCP
|
|
431
|
+
framing, not the CLI's `discover --save` nudge.
|
|
@@ -6,10 +6,10 @@ Status: `CHR-passed` (see `docs/MATRIX.md`). The MNDP codec, TTL cache, UDP
|
|
|
6
6
|
listener, and `discover --save` are implemented; the real-L2 receive/decode/save
|
|
7
7
|
path is green against RouterOS CHR 7.23.1 via `test/integration/discover.test.ts`
|
|
8
8
|
(socket-connect bridge), and the network-independent paths stay unit-tested. The
|
|
9
|
-
matrix remains the only status surface.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
matrix remains the only status surface. `--save` now writes the `identity=`/`mac=`
|
|
10
|
+
lookup keys and de-dupes on the MAC (both described below), so a discovered
|
|
11
|
+
device resolves by its advertised identity, IP, or MAC — not just the stored
|
|
12
|
+
`target`.
|
|
13
13
|
|
|
14
14
|
## Synopsis
|
|
15
15
|
|
|
@@ -66,7 +66,14 @@ tested without a router (crafted packet fixtures + a loopback socket).
|
|
|
66
66
|
`--save` without `--timeout` uses the default 15s window.
|
|
67
67
|
|
|
68
68
|
Bare `centrs discover` is **read-only** — it returns the envelope and never
|
|
69
|
-
writes the (WinBox-shared) CDB; `--save` is the explicit opt-in.
|
|
69
|
+
writes the (WinBox-shared) CDB; `--save` is the explicit opt-in. When it finds
|
|
70
|
+
neighbors but was not given `--save`, the envelope carries a `tip/discover-save`
|
|
71
|
+
pointer so the read-only run advertises how to persist the result. In the
|
|
72
|
+
human-facing text format on an interactive terminal (TTY) `discover` also prints
|
|
73
|
+
a one-line "listening…" progress note to **stderr** so the listen window does not
|
|
74
|
+
look like a hang; `--format json|yaml` and piped/redirected runs stay silent
|
|
75
|
+
(stderr is exempt from the lossless-stdout rule — see
|
|
76
|
+
[`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md), Result envelope). The registry
|
|
70
77
|
surface `centrs devices discover` is the inverse: invoking `devices` means you
|
|
71
78
|
intend to populate it, so **`--save` is implied** there, and it is the home for
|
|
72
79
|
the "set default credentials so centrs starts useful" onboarding nudge. Both
|
|
@@ -108,9 +115,11 @@ writer" holds.
|
|
|
108
115
|
globally unique — `identity` is *not* the de-dupe key, because factory-default
|
|
109
116
|
devices all report `MikroTik`. A neighbor whose MAC already names a CDB entry
|
|
110
117
|
(as the `target` of a `macTarget` record or as a `mac=` lookup key on any
|
|
111
|
-
record) is **skipped, never overwritten** — hand-curated records win.
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
record) is **skipped, never overwritten** — hand-curated records win. A
|
|
119
|
+
neighbor whose IP `target` already names an entry is likewise skipped, so a
|
|
120
|
+
curated record is never clobbered. Only genuinely new devices are added. Writes
|
|
121
|
+
reuse the `devices` atomic write path (`addDevice`); the CDB is reloaded between
|
|
122
|
+
writes so each add sees prior ones.
|
|
114
123
|
- **Encrypted CDBs:** `--save` against an encrypted CDB decrypts the file under
|
|
115
124
|
the loaded password, appends new neighbors, and re-encrypts with a fresh salt
|
|
116
125
|
before the atomic rename. The backup beside the CDB is the verbatim prior
|
|
@@ -63,9 +63,11 @@ centrs discover --save --cdb-file $CDB
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
New neighbors are added through the `devices` atomic write path with
|
|
66
|
-
`group=discovered`. Each saved comment
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
`group=discovered`. Each saved comment carries the `source=mndp` kv token, the
|
|
67
|
+
`identity=`/`mac=` **lookup keys** (so the device resolves by its advertised
|
|
68
|
+
identity, IP, or MAC), and a free-form parenthesized detail (`board:`,
|
|
69
|
+
`version:`, `interface:`, `software-id:`). `meta.operation.saved` reports
|
|
70
|
+
`{ group, added, skipped, records }`.
|
|
69
71
|
|
|
70
72
|
### 5. Save with a custom group
|
|
71
73
|
|
|
@@ -81,8 +83,11 @@ New entries get `group=lab` instead of `discovered`.
|
|
|
81
83
|
centrs discover --save --cdb-file $CDB
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
A neighbor whose
|
|
85
|
-
|
|
86
|
+
A neighbor whose **MAC** already names a CDB entry — as a `macTarget` record's
|
|
87
|
+
target or a `mac=` lookup key on any record (and likewise any record sharing the
|
|
88
|
+
neighbor's IP target) — is skipped, never overwritten. MAC is the de-dupe key
|
|
89
|
+
because it is globally unique and always advertised; `identity` is not, since
|
|
90
|
+
factory-default devices all report `MikroTik`. The skip is reported in
|
|
86
91
|
`meta.operation.saved.records[].action = "skipped-existing"`; hand-curated
|
|
87
92
|
records win.
|
|
88
93
|
|
package/package.json
CHANGED
package/src/cli/btest.ts
CHANGED
|
@@ -26,11 +26,19 @@ import {
|
|
|
26
26
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
27
27
|
import { describeCentrs, parseDuration } from "../index.ts";
|
|
28
28
|
import type { BtestDirection, BtestProtocol } from "../protocols/btest.ts";
|
|
29
|
+
import { toYaml } from "../retrieve.ts";
|
|
29
30
|
import {
|
|
30
31
|
type CliCommandMetadata,
|
|
31
32
|
expectValue,
|
|
32
33
|
renderCommandHelp,
|
|
33
34
|
} from "./common.ts";
|
|
35
|
+
import {
|
|
36
|
+
buildTargetSelectionTips,
|
|
37
|
+
cdbFileFromArgs,
|
|
38
|
+
formatTipsText,
|
|
39
|
+
isMissingTargetError,
|
|
40
|
+
missingTargetError,
|
|
41
|
+
} from "./missing-target.ts";
|
|
34
42
|
|
|
35
43
|
export const btestCommand: CliCommandMetadata = {
|
|
36
44
|
name: "btest",
|
|
@@ -355,6 +363,14 @@ async function runClient(args: readonly string[]): Promise<number> {
|
|
|
355
363
|
console.log(renderCommandHelp(describeCentrs(), btestCommand));
|
|
356
364
|
return 0;
|
|
357
365
|
}
|
|
366
|
+
if (request.targetInput === undefined) {
|
|
367
|
+
throw missingTargetError({
|
|
368
|
+
command: "btest client",
|
|
369
|
+
summary: "`centrs btest client` requires a <router> target.",
|
|
370
|
+
remediation:
|
|
371
|
+
"Pass the bandwidth-test server's host/identity, e.g. `centrs btest client 192.0.2.10`.",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
358
374
|
const streaming = format === "text" || format === "csv";
|
|
359
375
|
let csvHeader = false;
|
|
360
376
|
if (streaming) {
|
|
@@ -444,12 +460,40 @@ export async function runBtestCli(args: readonly string[]): Promise<number> {
|
|
|
444
460
|
`Unknown btest sub-command: ${sub}. Use \`client <router>\` or \`server\`.`,
|
|
445
461
|
);
|
|
446
462
|
} catch (error) {
|
|
463
|
+
// The parsed request (not present here) holds the raw password for auth;
|
|
464
|
+
// errors at this level are missing-target or arg-parse failures — neither
|
|
465
|
+
// carries credentials. CodeQL follows the password parsed earlier in
|
|
466
|
+
// runBtestCli and flags any console.error that touches centrsError; the
|
|
467
|
+
// password reaches neither the error nor these renderers, so the alerts are
|
|
468
|
+
// suppressed inline (same as the matching dismissals on terminal/transfer/execute).
|
|
447
469
|
const centrsError = asCentrsError(error, {
|
|
448
470
|
code: "input/invalid-command",
|
|
449
471
|
summary: error instanceof Error ? error.message : String(error),
|
|
450
472
|
remediation: "Use `centrs btest --help` to inspect the supported flags.",
|
|
451
473
|
});
|
|
452
|
-
|
|
474
|
+
const tips = isMissingTargetError(centrsError)
|
|
475
|
+
? await buildTargetSelectionTips({
|
|
476
|
+
cdbFile: cdbFileFromArgs(args),
|
|
477
|
+
env: Bun.env,
|
|
478
|
+
})
|
|
479
|
+
: [];
|
|
480
|
+
const format = args.includes("--json")
|
|
481
|
+
? "json"
|
|
482
|
+
: args.includes("--yaml")
|
|
483
|
+
? "yaml"
|
|
484
|
+
: "text";
|
|
485
|
+
if (format === "json" || format === "yaml") {
|
|
486
|
+
const out = {
|
|
487
|
+
ok: false as const,
|
|
488
|
+
error: { code: centrsError.code },
|
|
489
|
+
tips,
|
|
490
|
+
};
|
|
491
|
+
const serialized = format === "yaml" ? toYaml(out) : JSON.stringify(out);
|
|
492
|
+
console.error(serialized); // lgtm[js/clear-text-logging]
|
|
493
|
+
} else {
|
|
494
|
+
const text = formatCentrsErrorText(centrsError) + formatTipsText(tips);
|
|
495
|
+
console.error(text); // lgtm[js/clear-text-logging]
|
|
496
|
+
}
|
|
453
497
|
return 1;
|
|
454
498
|
}
|
|
455
499
|
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
DISCOVER_DEFAULT_TIMEOUT_MS,
|
|
9
10
|
type DiscoverOptions,
|
|
10
11
|
type DiscoverOutputFormat,
|
|
11
12
|
discover,
|
|
12
13
|
discoverOutputFormats,
|
|
14
|
+
MNDP_PORT,
|
|
13
15
|
renderDiscoverEnvelope,
|
|
14
16
|
} from "../discover.ts";
|
|
15
17
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
@@ -142,6 +144,29 @@ function parseDiscoverCliArgs(args: readonly string[]): DiscoverCliArgs {
|
|
|
142
144
|
return parsed;
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A 15s passive listen looks like a hang on an interactive terminal, so print a
|
|
149
|
+
* one-line progress note to **stderr** for the human-facing text format on a
|
|
150
|
+
* TTY. Skipped when piped/redirected (not a TTY) and for the `--format
|
|
151
|
+
* json|yaml` structured modes, so machine consumers never see it. stderr is the
|
|
152
|
+
* sanctioned channel for discover scan progress (`docs/CONSTITUTION.md` → Result
|
|
153
|
+
* envelope).
|
|
154
|
+
*/
|
|
155
|
+
function emitDiscoverProgress(
|
|
156
|
+
options: DiscoverOptions,
|
|
157
|
+
format: DiscoverOutputFormat | undefined,
|
|
158
|
+
): void {
|
|
159
|
+
if (format === "json" || format === "yaml" || !process.stderr.isTTY) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const windowMs = options.timeoutMs ?? DISCOVER_DEFAULT_TIMEOUT_MS;
|
|
163
|
+
const port = options.port ?? MNDP_PORT;
|
|
164
|
+
const hint = options.save ? "" : " (add --save to populate the CDB)";
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
`Listening for MNDP neighbors for ${Math.round(windowMs / 1000)}s on UDP ${port}…${hint}\n`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
145
170
|
export async function runDiscoverCli(args: readonly string[]): Promise<number> {
|
|
146
171
|
let parsed: DiscoverCliArgs | undefined;
|
|
147
172
|
try {
|
|
@@ -160,6 +185,7 @@ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
|
|
|
160
185
|
cdbPassword: parsed.cdbPassword,
|
|
161
186
|
env: Bun.env,
|
|
162
187
|
};
|
|
188
|
+
emitDiscoverProgress(options, parsed.format);
|
|
163
189
|
const envelope = await discover(options);
|
|
164
190
|
const format = parsed.format ?? "text";
|
|
165
191
|
const rendered = renderDiscoverEnvelope(envelope, format);
|
package/src/cli/execute.ts
CHANGED
|
@@ -13,6 +13,14 @@ import {
|
|
|
13
13
|
expectValue,
|
|
14
14
|
renderCommandHelp,
|
|
15
15
|
} from "./common.ts";
|
|
16
|
+
import {
|
|
17
|
+
buildTargetSelectionTips,
|
|
18
|
+
cdbFileFromArgs,
|
|
19
|
+
formatTipsText,
|
|
20
|
+
isMissingTargetError,
|
|
21
|
+
missingTargetError,
|
|
22
|
+
withTips,
|
|
23
|
+
} from "./missing-target.ts";
|
|
16
24
|
|
|
17
25
|
export const executeCommand: CliCommandMetadata = {
|
|
18
26
|
name: "execute",
|
|
@@ -241,6 +249,18 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
241
249
|
console.log(renderCommandHelp(describeCentrs(), executeCommand));
|
|
242
250
|
return 0;
|
|
243
251
|
}
|
|
252
|
+
// No positional at all: lead with the missing-target guidance (CDB picker /
|
|
253
|
+
// discover) rather than the missing-command error — there is nothing to run
|
|
254
|
+
// without a device. A lone positional is treated as the target, so the
|
|
255
|
+
// "requires a command" path still fires for `execute <target>`.
|
|
256
|
+
if (!parsed.targetInput) {
|
|
257
|
+
throw missingTargetError({
|
|
258
|
+
command: "execute",
|
|
259
|
+
summary: "`centrs execute` requires a <target> and a RouterOS command.",
|
|
260
|
+
remediation:
|
|
261
|
+
'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"`.',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
244
264
|
|
|
245
265
|
const envelope = await executeEnvelope(parsed);
|
|
246
266
|
const resolvedFormat =
|
|
@@ -264,10 +284,16 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
264
284
|
// Parse/usage errors land here too: surface them through the same typed
|
|
265
285
|
// envelope as every other runner (no raw message, no exit code 2).
|
|
266
286
|
const format = inferExecuteFormat(args, parsed);
|
|
287
|
+
const tips = isMissingTargetError(error)
|
|
288
|
+
? await buildTargetSelectionTips({
|
|
289
|
+
cdbFile: parsed?.cdbFile ?? cdbFileFromArgs(args),
|
|
290
|
+
env: Bun.env,
|
|
291
|
+
})
|
|
292
|
+
: [];
|
|
267
293
|
if (format === "json" || format === "yaml") {
|
|
268
|
-
const envelope =
|
|
269
|
-
parsed ?? { command: "" },
|
|
270
|
-
|
|
294
|
+
const envelope = withTips(
|
|
295
|
+
buildExecuteErrorEnvelope(parsed ?? { command: "" }, error),
|
|
296
|
+
tips,
|
|
271
297
|
);
|
|
272
298
|
console.error(
|
|
273
299
|
renderExecuteEnvelope(envelope, format, {
|
|
@@ -284,7 +310,7 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
284
310
|
"Use `centrs execute --help` to inspect the supported command shape and flags.",
|
|
285
311
|
}),
|
|
286
312
|
{ verbose: parsed?.verbose ?? args.includes("--verbose") },
|
|
287
|
-
),
|
|
313
|
+
) + formatTipsText(tips),
|
|
288
314
|
);
|
|
289
315
|
}
|
|
290
316
|
return 1;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "no `<router>` target" guidance for the CLI surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Every command that takes a `<router>` (terminal, execute, retrieve, transfer,
|
|
5
|
+
* btest client) raises the same `input/invalid-command` error when the target is
|
|
6
|
+
* missing, tagged with `context.missingTarget`. Each runner then calls
|
|
7
|
+
* {@link buildTargetSelectionTips} to turn that bare usage error into actionable
|
|
8
|
+
* advice: list a few saved CDB devices to pass as `<router>`, or — when the
|
|
9
|
+
* registry is empty — point at `centrs discover --save`. Tips ride the envelope's
|
|
10
|
+
* `tips` channel (`docs/CONSTITUTION.md` → Result envelope); this module also
|
|
11
|
+
* renders them for the **text** error path, which `formatCentrsErrorText` does
|
|
12
|
+
* not surface on its own.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { buildTip, type Tip } from "../core/envelope.ts";
|
|
16
|
+
import { defaultCdbPath, loadCdb } from "../devices.ts";
|
|
17
|
+
import { CentrsError } from "../errors.ts";
|
|
18
|
+
import { DEFAULT_RECORD_TARGET, identityFromComment } from "../resolver/cdb.ts";
|
|
19
|
+
|
|
20
|
+
/** Cap on how many saved devices a single tip names before "(+N more)". */
|
|
21
|
+
const MAX_LISTED_DEVICES = 8;
|
|
22
|
+
|
|
23
|
+
export interface MissingTargetErrorInit {
|
|
24
|
+
command: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
remediation: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The canonical "missing `<router>`" usage error. The `context.missingTarget`
|
|
31
|
+
* flag is the marker {@link isMissingTargetError} keys on so a runner knows to
|
|
32
|
+
* attach {@link buildTargetSelectionTips} output.
|
|
33
|
+
*/
|
|
34
|
+
export function missingTargetError(init: MissingTargetErrorInit): CentrsError {
|
|
35
|
+
return new CentrsError({
|
|
36
|
+
code: "input/invalid-command",
|
|
37
|
+
summary: init.summary,
|
|
38
|
+
remediation: init.remediation,
|
|
39
|
+
context: { missingTarget: true, command: init.command },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for an error raised by {@link missingTargetError} (or one tagged like it). */
|
|
44
|
+
export function isMissingTargetError(error: unknown): boolean {
|
|
45
|
+
return (
|
|
46
|
+
error instanceof CentrsError && error.context?.["missingTarget"] === true
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CdbInputs {
|
|
51
|
+
cdbFile?: string;
|
|
52
|
+
env?: Record<string, string | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pull the `--cdb-file` path out of raw args. Used by catch paths where the
|
|
57
|
+
* request never finished parsing (the missing-target throw happens mid-parse),
|
|
58
|
+
* so the request object is unavailable but the flag may still be on the command
|
|
59
|
+
* line. The CDB *password* is deliberately not extracted: a credential must
|
|
60
|
+
* never flow into the (logged) tip path, and the tips read only an unencrypted
|
|
61
|
+
* registry — an encrypted CDB falls back to generic guidance.
|
|
62
|
+
*/
|
|
63
|
+
export function cdbFileFromArgs(args: readonly string[]): string | undefined {
|
|
64
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
65
|
+
if (args[index] === "--cdb-file") {
|
|
66
|
+
if (index + 1 >= args.length) return undefined;
|
|
67
|
+
const value = args[index + 1] as string; // bounds-checked above
|
|
68
|
+
if (value.startsWith("--")) return undefined;
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Best-effort: inspect the CDB and turn "no `<router>`" into actionable tips —
|
|
77
|
+
* a list of saved targets when the registry has any, otherwise a pointer to
|
|
78
|
+
* `discover --save`. Never throws: an unreadable/encrypted CDB falls back to
|
|
79
|
+
* generic guidance, and a missing default CDB is reported as an empty registry
|
|
80
|
+
* (no file is created as a side effect of a usage error). The CDB password is
|
|
81
|
+
* intentionally never read here, so no credential can reach the rendered tip.
|
|
82
|
+
*/
|
|
83
|
+
export async function buildTargetSelectionTips(
|
|
84
|
+
inputs: CdbInputs,
|
|
85
|
+
): Promise<Tip[]> {
|
|
86
|
+
const env = inputs.env ?? {};
|
|
87
|
+
const explicitCdb =
|
|
88
|
+
inputs.cdbFile !== undefined || env["CENTRS_CDB_FILE"] !== undefined;
|
|
89
|
+
if (!explicitCdb && !(await Bun.file(defaultCdbPath(env)).exists())) {
|
|
90
|
+
return [emptyRegistryTip()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let entries: readonly { target: string; comment: string }[];
|
|
94
|
+
try {
|
|
95
|
+
const cdb = await loadCdb({
|
|
96
|
+
cdbFile: inputs.cdbFile,
|
|
97
|
+
env,
|
|
98
|
+
});
|
|
99
|
+
entries = cdb.entries.filter(
|
|
100
|
+
(entry) =>
|
|
101
|
+
entry.target.length > 0 && entry.target !== DEFAULT_RECORD_TARGET,
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
return [unreadableRegistryTip()];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entries.length === 0) {
|
|
108
|
+
return [emptyRegistryTip()];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const handles = entries
|
|
112
|
+
.slice(0, MAX_LISTED_DEVICES)
|
|
113
|
+
.map((entry) => identityFromComment(entry.comment, entry.target));
|
|
114
|
+
const extra =
|
|
115
|
+
entries.length > MAX_LISTED_DEVICES
|
|
116
|
+
? ` (+${entries.length - MAX_LISTED_DEVICES} more)`
|
|
117
|
+
: "";
|
|
118
|
+
return [
|
|
119
|
+
buildTip(
|
|
120
|
+
"tip/select-target",
|
|
121
|
+
`Pass one of your ${entries.length} saved device(s) as <router>: ${handles.join(", ")}${extra}.`,
|
|
122
|
+
"Run `centrs devices list` for every saved target; <router> also resolves by identity=, mac=, or ip=.",
|
|
123
|
+
),
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function emptyRegistryTip(): Tip {
|
|
128
|
+
return buildTip(
|
|
129
|
+
"tip/no-devices",
|
|
130
|
+
"No devices are saved in the CDB yet.",
|
|
131
|
+
"Run `centrs discover --save` to find RouterOS neighbors on the LAN and add them, then pass one as <router> (or `centrs devices add <target> --user … --password …`).",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unreadableRegistryTip(): Tip {
|
|
136
|
+
return buildTip(
|
|
137
|
+
"tip/select-target",
|
|
138
|
+
"Could not read the CDB to list saved devices (it may be encrypted — pass --cdb-password).",
|
|
139
|
+
"Run `centrs devices list` to see saved targets, or `centrs discover --save` to find and add neighbors on the LAN.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render tips for the **text** error path (mirrors the `devices` "Tips:"
|
|
145
|
+
* footer). Returns "" for an empty list so callers can append unconditionally.
|
|
146
|
+
*/
|
|
147
|
+
export function formatTipsText(tips: readonly Tip[]): string {
|
|
148
|
+
if (tips.length === 0) {
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
const lines = ["", "Tips:"];
|
|
152
|
+
for (const tip of tips) {
|
|
153
|
+
lines.push(` - [${tip.code}] ${tip.message}`);
|
|
154
|
+
if (tip.fix) {
|
|
155
|
+
lines.push(` fix: ${tip.fix}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Return a copy of an envelope with `tips` attached (no-op for an empty list).
|
|
163
|
+
* The `as E` keeps the call site's envelope type when an error envelope declares
|
|
164
|
+
* `tips: []` (an empty tuple) — we only ever widen it to a populated list.
|
|
165
|
+
*/
|
|
166
|
+
export function withTips<E extends { tips: readonly Tip[] }>(
|
|
167
|
+
envelope: E,
|
|
168
|
+
tips: readonly Tip[],
|
|
169
|
+
): E {
|
|
170
|
+
return tips.length > 0 ? ({ ...envelope, tips } as E) : envelope;
|
|
171
|
+
}
|
package/src/cli/retrieve.ts
CHANGED
|
@@ -22,6 +22,14 @@ import {
|
|
|
22
22
|
expectValue,
|
|
23
23
|
renderCommandHelp,
|
|
24
24
|
} from "./common.ts";
|
|
25
|
+
import {
|
|
26
|
+
buildTargetSelectionTips,
|
|
27
|
+
cdbFileFromArgs,
|
|
28
|
+
formatTipsText,
|
|
29
|
+
isMissingTargetError,
|
|
30
|
+
missingTargetError,
|
|
31
|
+
withTips,
|
|
32
|
+
} from "./missing-target.ts";
|
|
25
33
|
|
|
26
34
|
export const retrieveCommand: CliCommandMetadata = {
|
|
27
35
|
name: "retrieve",
|
|
@@ -171,6 +179,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
171
179
|
return 0;
|
|
172
180
|
} catch (error) {
|
|
173
181
|
const format = inferRequestedFormat(args, request);
|
|
182
|
+
const tips = isMissingTargetError(error)
|
|
183
|
+
? await buildTargetSelectionTips({
|
|
184
|
+
cdbFile: request?.cdbFile ?? cdbFileFromArgs(args),
|
|
185
|
+
env: Bun.env,
|
|
186
|
+
})
|
|
187
|
+
: [];
|
|
174
188
|
if (format === "json" || format === "yaml") {
|
|
175
189
|
if (request?.group) {
|
|
176
190
|
const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
|
|
@@ -180,9 +194,14 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
180
194
|
}),
|
|
181
195
|
);
|
|
182
196
|
} else {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
// When parsing failed before a request existed, build the error
|
|
198
|
+
// envelope from an empty request rather than reconstructing positionals
|
|
199
|
+
// from raw args — a credential value (e.g. the token after
|
|
200
|
+
// `--password` / `--cdb-password`) would otherwise be echoed as
|
|
201
|
+
// `meta.target.input`.
|
|
202
|
+
const envelope = withTips(
|
|
203
|
+
buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
|
|
204
|
+
tips,
|
|
186
205
|
);
|
|
187
206
|
console.error(
|
|
188
207
|
renderRetrieveEnvelope(envelope, format, {
|
|
@@ -202,7 +221,7 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
202
221
|
{
|
|
203
222
|
verbose: request?.verbose ?? args.includes("--verbose"),
|
|
204
223
|
},
|
|
205
|
-
),
|
|
224
|
+
) + formatTipsText(tips),
|
|
206
225
|
);
|
|
207
226
|
}
|
|
208
227
|
return 1;
|
|
@@ -341,9 +360,17 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
341
360
|
return request;
|
|
342
361
|
}
|
|
343
362
|
|
|
363
|
+
if (positional.length === 0) {
|
|
364
|
+
throw missingTargetError({
|
|
365
|
+
command: "retrieve",
|
|
366
|
+
summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
|
|
367
|
+
remediation:
|
|
368
|
+
"Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
344
371
|
if (positional.length < 2) {
|
|
345
372
|
throw new Error(
|
|
346
|
-
"`centrs retrieve` requires
|
|
373
|
+
"`centrs retrieve` requires a <routeros-path> after the <target>.",
|
|
347
374
|
);
|
|
348
375
|
}
|
|
349
376
|
|
|
@@ -372,12 +399,3 @@ function inferRequestedFormat(
|
|
|
372
399
|
}
|
|
373
400
|
return "text";
|
|
374
401
|
}
|
|
375
|
-
|
|
376
|
-
function fallbackRequestFromArgs(args: readonly string[]): RetrieveRequest {
|
|
377
|
-
const positionals = args.filter((arg) => !arg.startsWith("-"));
|
|
378
|
-
return {
|
|
379
|
-
targetInput: positionals[0],
|
|
380
|
-
path: positionals[1] ?? "",
|
|
381
|
-
format: inferRequestedFormat(args),
|
|
382
|
-
};
|
|
383
|
-
}
|
package/src/cli/terminal.ts
CHANGED
|
@@ -20,6 +20,14 @@ import {
|
|
|
20
20
|
expectValue,
|
|
21
21
|
renderCommandHelp,
|
|
22
22
|
} from "./common.ts";
|
|
23
|
+
import {
|
|
24
|
+
buildTargetSelectionTips,
|
|
25
|
+
cdbFileFromArgs,
|
|
26
|
+
formatTipsText,
|
|
27
|
+
isMissingTargetError,
|
|
28
|
+
missingTargetError,
|
|
29
|
+
withTips,
|
|
30
|
+
} from "./missing-target.ts";
|
|
23
31
|
|
|
24
32
|
export const terminalCommand: CliCommandMetadata = {
|
|
25
33
|
name: "terminal",
|
|
@@ -164,8 +172,14 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
|
|
|
164
172
|
// error nor these renderers, so the alert is dismissed as a false positive
|
|
165
173
|
// repo-wide — see the matching dismissals on src/cli/{transfer,execute}.ts.
|
|
166
174
|
const format = inferRequestedFormat(args);
|
|
175
|
+
const tips = isMissingTargetError(error)
|
|
176
|
+
? await buildTargetSelectionTips({
|
|
177
|
+
cdbFile: request?.cdbFile ?? cdbFileFromArgs(args),
|
|
178
|
+
env: Bun.env,
|
|
179
|
+
})
|
|
180
|
+
: [];
|
|
167
181
|
if (format === "json" || format === "yaml") {
|
|
168
|
-
const envelope = buildTerminalErrorEnvelope(error);
|
|
182
|
+
const envelope = withTips(buildTerminalErrorEnvelope(error), tips);
|
|
169
183
|
console.error(
|
|
170
184
|
format === "yaml" ? toYaml(envelope) : JSON.stringify(envelope),
|
|
171
185
|
);
|
|
@@ -179,7 +193,7 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
|
|
|
179
193
|
"Use `centrs terminal --help` for the supported command shape and flags.",
|
|
180
194
|
}),
|
|
181
195
|
{ verbose: args.includes("--verbose") },
|
|
182
|
-
),
|
|
196
|
+
) + formatTipsText(tips),
|
|
183
197
|
);
|
|
184
198
|
}
|
|
185
199
|
return 1;
|
|
@@ -272,8 +286,8 @@ function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
|
|
|
272
286
|
}
|
|
273
287
|
const targetInput = positional[0];
|
|
274
288
|
if (!targetInput) {
|
|
275
|
-
throw
|
|
276
|
-
|
|
289
|
+
throw missingTargetError({
|
|
290
|
+
command: "terminal",
|
|
277
291
|
summary: "`centrs terminal` requires a <router> target.",
|
|
278
292
|
remediation:
|
|
279
293
|
"Pass the device MAC (or a CDB identity/ip) as the first argument; run `centrs terminal --help` for the shape.",
|
package/src/cli/transfer.ts
CHANGED
|
@@ -24,6 +24,14 @@ import {
|
|
|
24
24
|
expectValue,
|
|
25
25
|
renderCommandHelp,
|
|
26
26
|
} from "./common.ts";
|
|
27
|
+
import {
|
|
28
|
+
buildTargetSelectionTips,
|
|
29
|
+
cdbFileFromArgs,
|
|
30
|
+
formatTipsText,
|
|
31
|
+
isMissingTargetError,
|
|
32
|
+
missingTargetError,
|
|
33
|
+
withTips,
|
|
34
|
+
} from "./missing-target.ts";
|
|
27
35
|
|
|
28
36
|
/** Sub-verb aliases, resolved to canonical verbs silently (commands/AGENTS.md). */
|
|
29
37
|
const VERB_ALIASES: Record<string, TransferVerb> = {
|
|
@@ -173,10 +181,19 @@ export async function runTransferCli(
|
|
|
173
181
|
// (CodeQL js/clear-text-logging).
|
|
174
182
|
const safeRequest = redactTransferRequest(request);
|
|
175
183
|
const format = inferRequestedFormat(args, safeRequest);
|
|
184
|
+
const tips = isMissingTargetError(error)
|
|
185
|
+
? await buildTargetSelectionTips({
|
|
186
|
+
cdbFile: safeRequest?.cdbFile ?? cdbFileFromArgs(args),
|
|
187
|
+
env: Bun.env,
|
|
188
|
+
})
|
|
189
|
+
: [];
|
|
176
190
|
if (format === "json" || format === "yaml") {
|
|
177
|
-
const envelope =
|
|
178
|
-
|
|
179
|
-
|
|
191
|
+
const envelope = withTips(
|
|
192
|
+
buildTransferErrorEnvelope(
|
|
193
|
+
safeRequest ?? { verb: options.fixedVerb ?? "list" },
|
|
194
|
+
error,
|
|
195
|
+
),
|
|
196
|
+
tips,
|
|
180
197
|
);
|
|
181
198
|
console.error(
|
|
182
199
|
renderTransferEnvelope(envelope, format, {
|
|
@@ -193,7 +210,7 @@ export async function runTransferCli(
|
|
|
193
210
|
"Use `centrs transfer --help` to inspect the supported command shape and flags.",
|
|
194
211
|
}),
|
|
195
212
|
{ verbose: safeRequest?.verbose ?? args.includes("--verbose") },
|
|
196
|
-
),
|
|
213
|
+
) + formatTipsText(tips),
|
|
197
214
|
);
|
|
198
215
|
}
|
|
199
216
|
return 1;
|
|
@@ -367,13 +384,24 @@ function assemblePositionals(
|
|
|
367
384
|
} else {
|
|
368
385
|
targetInput = positional[0];
|
|
369
386
|
const rawVerb = positional[1];
|
|
370
|
-
|
|
387
|
+
// A missing target gets the tagged missing-target error (CDB picker tip);
|
|
388
|
+
// a present target with no verb is a distinct "which verb?" usage error.
|
|
389
|
+
if (!targetInput) {
|
|
390
|
+
throw missingTargetError({
|
|
391
|
+
command: "transfer",
|
|
392
|
+
summary:
|
|
393
|
+
"`centrs transfer` requires a <router> target and a verb (upload|download|list|remove|mkdir|copy).",
|
|
394
|
+
remediation:
|
|
395
|
+
"Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (!rawVerb) {
|
|
371
399
|
throw new CentrsError({
|
|
372
400
|
code: "input/invalid-command",
|
|
373
401
|
summary:
|
|
374
|
-
"`centrs transfer` requires <router>
|
|
402
|
+
"`centrs transfer` requires a verb after the <router> (upload|download|list|remove|mkdir|copy).",
|
|
375
403
|
remediation:
|
|
376
|
-
"Pass
|
|
404
|
+
"Pass a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
377
405
|
context: { targetInput, verb: rawVerb },
|
|
378
406
|
});
|
|
379
407
|
}
|
|
@@ -382,8 +410,8 @@ function assemblePositionals(
|
|
|
382
410
|
}
|
|
383
411
|
|
|
384
412
|
if (!targetInput) {
|
|
385
|
-
throw
|
|
386
|
-
|
|
413
|
+
throw missingTargetError({
|
|
414
|
+
command: "transfer",
|
|
387
415
|
summary: "`centrs transfer` requires a <router> target.",
|
|
388
416
|
remediation:
|
|
389
417
|
"Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
|
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[],
|