@tikoci/centrs 0.1.2 → 0.1.4
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 +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
package/src/devices.ts
CHANGED
|
@@ -38,10 +38,24 @@ import {
|
|
|
38
38
|
applyCommentKv,
|
|
39
39
|
type CommentKvUpdate,
|
|
40
40
|
commentKvAllowlist,
|
|
41
|
+
commentKvGeoKeys,
|
|
41
42
|
commentKvLookupKeys,
|
|
42
43
|
commentKvReservedKeys,
|
|
43
44
|
parseCommentKv,
|
|
45
|
+
parseRawCommentFacts,
|
|
44
46
|
} from "./resolver/comment-kv.ts";
|
|
47
|
+
import { entryFacts, entryLocation, matchesWhere } from "./resolver/facts.ts";
|
|
48
|
+
import {
|
|
49
|
+
type BboxPredicate,
|
|
50
|
+
type DeviceLocation,
|
|
51
|
+
deviceLocation,
|
|
52
|
+
matchesBbox,
|
|
53
|
+
matchesNear,
|
|
54
|
+
type NearPredicate,
|
|
55
|
+
parseAltitude,
|
|
56
|
+
parseAltitudeType,
|
|
57
|
+
parseLatLon,
|
|
58
|
+
} from "./resolver/geo.ts";
|
|
45
59
|
import { normalizeMac } from "./resolver/mac.ts";
|
|
46
60
|
|
|
47
61
|
export type { SettingSource, SettingSourceKind };
|
|
@@ -66,6 +80,8 @@ export interface DevicesListItem {
|
|
|
66
80
|
cdbRecordIndex: number;
|
|
67
81
|
source?: string;
|
|
68
82
|
sources?: Record<string, SettingSource>;
|
|
83
|
+
/** GPS location, parsed from the `lat`/`lon`/`altitude`/`altitude-type` comment facts (issue #146). */
|
|
84
|
+
location?: DeviceLocation;
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
export interface DevicesShowItem extends DevicesListItem {
|
|
@@ -126,6 +142,13 @@ export interface LoadCdbOptions {
|
|
|
126
142
|
cdbFile?: string;
|
|
127
143
|
cdbPassword?: string;
|
|
128
144
|
env?: Record<string, string | undefined>;
|
|
145
|
+
/**
|
|
146
|
+
* The `centrs.env` config tier (`src/resolver/config-file.ts`). Only
|
|
147
|
+
* `CENTRS_CDB_FILE` reads it — `CENTRS_CDB_PASSWORD` deliberately never
|
|
148
|
+
* does, same reasoning as `CENTRS_USERNAME`/`CENTRS_PASSWORD` in
|
|
149
|
+
* `resolver/target.ts`'s `resolveAuth`.
|
|
150
|
+
*/
|
|
151
|
+
config?: Record<string, string | undefined>;
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
export interface LoadedCdb {
|
|
@@ -154,6 +177,11 @@ export function resolveDevicesSettings(
|
|
|
154
177
|
const env = options.env ?? {};
|
|
155
178
|
let cdbFileSource: SettingSource = { kind: "default", key: "default" };
|
|
156
179
|
let cdbFile = defaultCdbPath(env);
|
|
180
|
+
const configCdbFile = options.config?.[ENV_CDB_FILE];
|
|
181
|
+
if (configCdbFile) {
|
|
182
|
+
cdbFile = configCdbFile;
|
|
183
|
+
cdbFileSource = { kind: "config", key: ENV_CDB_FILE };
|
|
184
|
+
}
|
|
157
185
|
const envCdbFile = env[ENV_CDB_FILE];
|
|
158
186
|
if (envCdbFile) {
|
|
159
187
|
cdbFile = envCdbFile;
|
|
@@ -197,10 +225,12 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
197
225
|
* Create an empty open CDB at `path` without clobbering an existing file.
|
|
198
226
|
* Uses an exclusive-create open (`wx`) so a concurrent writer's CDB is never
|
|
199
227
|
* overwritten; the caller treats EEXIST as "another process created it".
|
|
228
|
+
* Mode `0o600` keeps the CDB (device credentials) unreadable by other local
|
|
229
|
+
* users instead of falling back to the process umask default.
|
|
200
230
|
*/
|
|
201
231
|
async function createEmptyCdbNoClobber(path: string): Promise<void> {
|
|
202
232
|
await mkdir(dirname(path), { recursive: true });
|
|
203
|
-
const handle = await open(path, "wx");
|
|
233
|
+
const handle = await open(path, "wx", 0o600);
|
|
204
234
|
try {
|
|
205
235
|
await handle.write(encodeOpenWinBoxCdb([]));
|
|
206
236
|
await handle.sync();
|
|
@@ -434,9 +464,21 @@ function credentialsMissingTips(
|
|
|
434
464
|
];
|
|
435
465
|
}
|
|
436
466
|
|
|
467
|
+
/** One `--where attr=value` device-class clause for `devices list` (exact-match, AND-combined). */
|
|
468
|
+
export interface DevicesWhereClause {
|
|
469
|
+
key: string;
|
|
470
|
+
value: string;
|
|
471
|
+
}
|
|
472
|
+
|
|
437
473
|
export interface ListDevicesArgs {
|
|
438
474
|
cdb: LoadedCdb;
|
|
439
475
|
group?: string;
|
|
476
|
+
/** Repeatable `--where attr=value`, AND-combined (constitution: target selection grammar). */
|
|
477
|
+
where?: readonly DevicesWhereClause[];
|
|
478
|
+
/** `--near <lat>,<lon>,<radius>`: keep devices whose GPS is within radius. */
|
|
479
|
+
near?: NearPredicate;
|
|
480
|
+
/** `--bbox <south>,<west>,<north>,<east>`: keep devices whose GPS is in the box. */
|
|
481
|
+
bbox?: BboxPredicate;
|
|
440
482
|
}
|
|
441
483
|
|
|
442
484
|
export function listDevices(
|
|
@@ -446,18 +488,27 @@ export function listDevices(
|
|
|
446
488
|
let entries = args.cdb.entries;
|
|
447
489
|
let indices = entries.map((_, index) => index);
|
|
448
490
|
|
|
449
|
-
|
|
450
|
-
|
|
491
|
+
// Each selector narrows the working (entries, indices) pair (AND-combined —
|
|
492
|
+
// `devices list` is a *filter* surface, distinct from the fan-out resolver's
|
|
493
|
+
// union). `--where` uses the shared `entryFacts`/`matchesWhere` from
|
|
494
|
+
// `src/resolver/facts.ts`, so the list filter and the fan-out selector match
|
|
495
|
+
// against identical device-class facts.
|
|
496
|
+
const narrow = (keep: (entry: WinBoxCdbEntry) => boolean): void => {
|
|
451
497
|
const filtered: { entry: WinBoxCdbEntry; index: number }[] = [];
|
|
452
|
-
for (let
|
|
453
|
-
const entry = entries[
|
|
454
|
-
if (entry && entry
|
|
455
|
-
filtered.push({ entry, index });
|
|
498
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
499
|
+
const entry = entries[position];
|
|
500
|
+
if (entry && keep(entry)) {
|
|
501
|
+
filtered.push({ entry, index: indices[position] ?? -1 });
|
|
456
502
|
}
|
|
457
503
|
}
|
|
458
504
|
entries = filtered.map((item) => item.entry);
|
|
459
505
|
indices = filtered.map((item) => item.index);
|
|
460
|
-
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
if (args.group !== undefined) {
|
|
509
|
+
const wanted = args.group;
|
|
510
|
+
narrow((entry) => entry.group === wanted);
|
|
511
|
+
if (entries.length === 0) {
|
|
461
512
|
warnings.push({
|
|
462
513
|
code: "cdb/empty-group",
|
|
463
514
|
message: `No entries matched group "${wanted}".`,
|
|
@@ -466,6 +517,30 @@ export function listDevices(
|
|
|
466
517
|
}
|
|
467
518
|
}
|
|
468
519
|
|
|
520
|
+
if (args.where && args.where.length > 0) {
|
|
521
|
+
const where = args.where;
|
|
522
|
+
narrow((entry) =>
|
|
523
|
+
matchesWhere(entryFacts(entry.comment, entry.target, entry.group), where),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (args.near !== undefined || args.bbox !== undefined) {
|
|
528
|
+
const { near, bbox } = args;
|
|
529
|
+
// Geo predicates union among themselves (near OR bbox, like the fan-out
|
|
530
|
+
// resolver), then AND-compose with group/where above. A geo-less device
|
|
531
|
+
// carries no location and is silently excluded (not an error).
|
|
532
|
+
narrow((entry) => {
|
|
533
|
+
const loc = entryLocation(entry.comment);
|
|
534
|
+
if (loc === undefined) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
return (
|
|
538
|
+
(near !== undefined && matchesNear(loc, near)) ||
|
|
539
|
+
(bbox !== undefined && matchesBbox(loc, bbox))
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
469
544
|
const data: DevicesListItem[] = entries.map((entry, position) =>
|
|
470
545
|
entryToListItem(entry, indices[position] ?? -1),
|
|
471
546
|
);
|
|
@@ -677,6 +752,10 @@ function entryToListItem(
|
|
|
677
752
|
key: `record:${cdbRecordIndex}:source`,
|
|
678
753
|
};
|
|
679
754
|
}
|
|
755
|
+
const location = deviceLocation(parseRawCommentFacts(entry.comment));
|
|
756
|
+
if (location !== undefined) {
|
|
757
|
+
item.location = location;
|
|
758
|
+
}
|
|
680
759
|
return item;
|
|
681
760
|
}
|
|
682
761
|
|
|
@@ -805,7 +884,7 @@ function entryToShowItem(
|
|
|
805
884
|
entry: WinBoxCdbEntry,
|
|
806
885
|
cdbRecordIndex: number,
|
|
807
886
|
): DevicesShowItem {
|
|
808
|
-
|
|
887
|
+
const item: DevicesShowItem = {
|
|
809
888
|
target: entry.target,
|
|
810
889
|
recordType: entry.recordType,
|
|
811
890
|
recordTypeName: recordTypeName(entry.recordType),
|
|
@@ -821,6 +900,11 @@ function entryToShowItem(
|
|
|
821
900
|
flags: entry.flags,
|
|
822
901
|
cdbRecordIndex,
|
|
823
902
|
};
|
|
903
|
+
const location = deviceLocation(parseRawCommentFacts(entry.comment));
|
|
904
|
+
if (location !== undefined) {
|
|
905
|
+
item.location = location;
|
|
906
|
+
}
|
|
907
|
+
return item;
|
|
824
908
|
}
|
|
825
909
|
|
|
826
910
|
export interface ListGroupsArgs {
|
|
@@ -1094,6 +1178,7 @@ async function persistRecords(
|
|
|
1094
1178
|
const ALLOWLIST_SET = new Set<string>(commentKvAllowlist);
|
|
1095
1179
|
const RESERVED_SET = new Set<string>(commentKvReservedKeys);
|
|
1096
1180
|
const LOOKUP_SET = new Set<string>(commentKvLookupKeys);
|
|
1181
|
+
const GEO_SET = new Set<string>(commentKvGeoKeys);
|
|
1097
1182
|
|
|
1098
1183
|
/**
|
|
1099
1184
|
* The `usage/not-implemented` error raised by `devices edit`, whose interactive
|
|
@@ -1110,19 +1195,92 @@ export function editInteractiveOnlyError(): CentrsError {
|
|
|
1110
1195
|
});
|
|
1111
1196
|
}
|
|
1112
1197
|
|
|
1198
|
+
/**
|
|
1199
|
+
* Validate + (for the one enum field) normalize a single geo update's value.
|
|
1200
|
+
* `lat`/`lon`/`altitude` are validated but returned verbatim (stored exactly
|
|
1201
|
+
* as typed); `altitude-type` is validated AND canonicalized to upper case,
|
|
1202
|
+
* since it is a closed two-value enum rather than a freeform/numeric field
|
|
1203
|
+
* (see `src/resolver/geo.ts`). Throws `input/invalid-coordinate` /
|
|
1204
|
+
* `input/invalid-altitude` on a bad value; a `value: null` removal passes
|
|
1205
|
+
* through untouched (nothing to validate).
|
|
1206
|
+
*/
|
|
1207
|
+
function normalizeGeoUpdate(update: CommentKvUpdate): CommentKvUpdate {
|
|
1208
|
+
if (update.value === null) {
|
|
1209
|
+
return update;
|
|
1210
|
+
}
|
|
1211
|
+
switch (update.key) {
|
|
1212
|
+
case "lat":
|
|
1213
|
+
parseLatLon(update.value, "lat");
|
|
1214
|
+
return update;
|
|
1215
|
+
case "lon":
|
|
1216
|
+
parseLatLon(update.value, "lon");
|
|
1217
|
+
return update;
|
|
1218
|
+
case "altitude":
|
|
1219
|
+
parseAltitude(update.value);
|
|
1220
|
+
return update;
|
|
1221
|
+
case "altitude-type":
|
|
1222
|
+
return { key: update.key, value: parseAltitudeType(update.value) };
|
|
1223
|
+
default:
|
|
1224
|
+
return update;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* `lat`/`lon` must land together: setting one without the other, when the
|
|
1230
|
+
* other is not already on the record, is `input/incomplete-gps` (also catches
|
|
1231
|
+
* removing one half of an existing pair). Checked against the MERGE of
|
|
1232
|
+
* `baseComment` (the record's existing comment for `set`, or the base
|
|
1233
|
+
* `--comment` for `add`) and `updates`, so an already-complete record editing
|
|
1234
|
+
* an unrelated key never trips this.
|
|
1235
|
+
*/
|
|
1236
|
+
function validateGeoPairing(
|
|
1237
|
+
baseComment: string,
|
|
1238
|
+
updates: readonly CommentKvUpdate[],
|
|
1239
|
+
target: string,
|
|
1240
|
+
): void {
|
|
1241
|
+
const priorFacts = parseRawCommentFacts(baseComment);
|
|
1242
|
+
let lat = priorFacts["lat"];
|
|
1243
|
+
let lon = priorFacts["lon"];
|
|
1244
|
+
for (const update of updates) {
|
|
1245
|
+
if (update.key === "lat") {
|
|
1246
|
+
lat = update.value === null ? undefined : update.value;
|
|
1247
|
+
} else if (update.key === "lon") {
|
|
1248
|
+
lon = update.value === null ? undefined : update.value;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const hasLat = lat !== undefined;
|
|
1252
|
+
const hasLon = lon !== undefined;
|
|
1253
|
+
if (hasLat === hasLon) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
throw new CentrsError({
|
|
1257
|
+
code: "input/incomplete-gps",
|
|
1258
|
+
summary: `Target "${target}" would end up with only ${hasLat ? "lat" : "lon"} set; lat and lon must be set together.`,
|
|
1259
|
+
remediation:
|
|
1260
|
+
"Pass both --lat and --lon (or lat=... and lon=... together), or use --gps <lat>,<lon>[,<altitude>[,<altitude-type>]] to set both at once.",
|
|
1261
|
+
context: { target, hasLat, hasLon },
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1113
1265
|
/**
|
|
1114
1266
|
* Validate the `k=v` comment positionals shared by `add` and `set`. First-class
|
|
1115
1267
|
* CDB fields are reserved and throw `cdb/reserved-key` (use the matching flag);
|
|
1116
|
-
* tokens outside the recognized override + lookup keys throw
|
|
1117
|
-
* under `strict`, else append a `cdb/unknown-option`
|
|
1118
|
-
* (allowlist overrides
|
|
1268
|
+
* tokens outside the recognized override + lookup + geo keys throw
|
|
1269
|
+
* `cdb/unknown-option` under `strict`, else append a `cdb/unknown-option`
|
|
1270
|
+
* warning. Recognized keys (allowlist overrides, `identity`/`mac`/`ip`
|
|
1271
|
+
* lookups, `lat`/`lon`/`altitude`/`altitude-type` geo facts) pass silently.
|
|
1272
|
+
* Geo values are further validated (range/enum) and checked for `lat`/`lon`
|
|
1273
|
+
* pairing via `src/resolver/geo.ts`; returns the updates with `altitude-type`
|
|
1274
|
+
* normalized to its canonical upper-case form (the only geo field that is not
|
|
1275
|
+
* stored verbatim — see `normalizeGeoUpdate`).
|
|
1119
1276
|
*/
|
|
1120
1277
|
function validateCommentKvUpdates(
|
|
1121
1278
|
updates: readonly CommentKvUpdate[],
|
|
1122
1279
|
target: string,
|
|
1123
1280
|
strict: boolean | undefined,
|
|
1124
1281
|
warnings: DevicesWarning[],
|
|
1125
|
-
|
|
1282
|
+
baseComment = "",
|
|
1283
|
+
): CommentKvUpdate[] {
|
|
1126
1284
|
const reserved = updates
|
|
1127
1285
|
.map((update) => update.key)
|
|
1128
1286
|
.filter((key) => RESERVED_SET.has(key));
|
|
@@ -1130,12 +1288,17 @@ function validateCommentKvUpdates(
|
|
|
1130
1288
|
throw new CentrsError({
|
|
1131
1289
|
code: "cdb/reserved-key",
|
|
1132
1290
|
summary: `Comment kv-soup cannot carry first-class CDB field(s): ${reserved.join(", ")}.`,
|
|
1133
|
-
remediation: `Set ${reserved.join(", ")} with the matching --flag; the comment kv-soup is for ${commentKvAllowlist.join(", ")} plus identity/mac/ip.`,
|
|
1291
|
+
remediation: `Set ${reserved.join(", ")} with the matching --flag; the comment kv-soup is for ${commentKvAllowlist.join(", ")} plus identity/mac/ip/${commentKvGeoKeys.join("/")}.`,
|
|
1134
1292
|
context: { target, reservedKeys: reserved },
|
|
1135
1293
|
});
|
|
1136
1294
|
}
|
|
1295
|
+
|
|
1296
|
+
const normalized = updates.map((update) =>
|
|
1297
|
+
GEO_SET.has(update.key) ? normalizeGeoUpdate(update) : update,
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1137
1300
|
for (const key of updates.map((update) => update.key)) {
|
|
1138
|
-
if (ALLOWLIST_SET.has(key) || LOOKUP_SET.has(key)) {
|
|
1301
|
+
if (ALLOWLIST_SET.has(key) || LOOKUP_SET.has(key) || GEO_SET.has(key)) {
|
|
1139
1302
|
continue;
|
|
1140
1303
|
}
|
|
1141
1304
|
if (strict) {
|
|
@@ -1152,6 +1315,17 @@ function validateCommentKvUpdates(
|
|
|
1152
1315
|
context: { target, key },
|
|
1153
1316
|
});
|
|
1154
1317
|
}
|
|
1318
|
+
|
|
1319
|
+
// Only enforce lat/lon pairing when THIS update actually touches a coordinate
|
|
1320
|
+
// (add or remove). Otherwise an unrelated `set timeout=…` on a record that
|
|
1321
|
+
// already carries a legacy/hand-edited incomplete pair would spuriously fail
|
|
1322
|
+
// with `input/incomplete-gps`; leave a pre-existing pair untouched.
|
|
1323
|
+
if (
|
|
1324
|
+
normalized.some((update) => update.key === "lat" || update.key === "lon")
|
|
1325
|
+
) {
|
|
1326
|
+
validateGeoPairing(baseComment, normalized, target);
|
|
1327
|
+
}
|
|
1328
|
+
return normalized;
|
|
1155
1329
|
}
|
|
1156
1330
|
|
|
1157
1331
|
export interface AddDeviceArgs {
|
|
@@ -1192,8 +1366,13 @@ export async function addDevice(
|
|
|
1192
1366
|
}
|
|
1193
1367
|
|
|
1194
1368
|
const warnings: DevicesWarning[] = [...args.cdb.warnings];
|
|
1195
|
-
const updates =
|
|
1196
|
-
|
|
1369
|
+
const updates = validateCommentKvUpdates(
|
|
1370
|
+
args.commentKvUpdates ?? [],
|
|
1371
|
+
args.target,
|
|
1372
|
+
args.strict,
|
|
1373
|
+
warnings,
|
|
1374
|
+
args.comment ?? "",
|
|
1375
|
+
);
|
|
1197
1376
|
const comment =
|
|
1198
1377
|
updates.length > 0 || args.comment !== undefined
|
|
1199
1378
|
? applyCommentKv(args.comment ?? "", updates)
|
|
@@ -1281,11 +1460,16 @@ export async function setDevice(
|
|
|
1281
1460
|
args: SetDeviceArgs,
|
|
1282
1461
|
): Promise<DevicesMutationEnvelope> {
|
|
1283
1462
|
const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
|
|
1284
|
-
const
|
|
1463
|
+
const prior = match.entry;
|
|
1285
1464
|
const warnings: DevicesWarning[] = [...args.cdb.warnings];
|
|
1286
|
-
|
|
1465
|
+
const updates = validateCommentKvUpdates(
|
|
1466
|
+
args.updates ?? [],
|
|
1467
|
+
args.target,
|
|
1468
|
+
args.strict,
|
|
1469
|
+
warnings,
|
|
1470
|
+
prior.comment,
|
|
1471
|
+
);
|
|
1287
1472
|
|
|
1288
|
-
const prior = match.entry;
|
|
1289
1473
|
const comment = applyCommentKv(prior.comment, updates);
|
|
1290
1474
|
const carried = extraFields(prior.record);
|
|
1291
1475
|
|
|
@@ -1669,6 +1853,14 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
|
|
|
1669
1853
|
lines.push(`profile: ${entry.profile || "-"}`);
|
|
1670
1854
|
lines.push(`session: ${entry.session || "-"}`);
|
|
1671
1855
|
lines.push(`comment: ${entry.comment || "-"}`);
|
|
1856
|
+
if (entry.location) {
|
|
1857
|
+
const { lat, lon, altitude, altitudeType } = entry.location;
|
|
1858
|
+
const altitudePart =
|
|
1859
|
+
altitude !== undefined
|
|
1860
|
+
? ` altitude=${altitude}${altitudeType ? ` (${altitudeType})` : ""}`
|
|
1861
|
+
: "";
|
|
1862
|
+
lines.push(`location: lat=${lat} lon=${lon}${altitudePart}`);
|
|
1863
|
+
}
|
|
1672
1864
|
if (entry.romonAgent) {
|
|
1673
1865
|
lines.push(`romon-agent: ${entry.romonAgent}`);
|
|
1674
1866
|
}
|
package/src/discover.ts
CHANGED
|
@@ -50,6 +50,11 @@ export const DISCOVER_DEFAULT_REFRESH_INTERVAL_MS = 5_000;
|
|
|
50
50
|
/** Default CDB group assigned to saved neighbors. */
|
|
51
51
|
export const DISCOVER_DEFAULT_GROUP = "discovered";
|
|
52
52
|
|
|
53
|
+
/** MNDP uses SO_REUSEPORT where available; Windows does not support it. */
|
|
54
|
+
export function defaultMndpReusePort(platform = process.platform): boolean {
|
|
55
|
+
return platform !== "win32";
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
export interface ListenMndpOptions {
|
|
54
59
|
/** Listen window in ms before the listener resolves. Default 15000. */
|
|
55
60
|
timeoutMs?: number;
|
|
@@ -69,7 +74,7 @@ export interface ListenMndpOptions {
|
|
|
69
74
|
refreshIntervalMs?: number;
|
|
70
75
|
/** Enable `SO_REUSEADDR`. Default true. */
|
|
71
76
|
reuseAddr?: boolean;
|
|
72
|
-
/** Enable `SO_REUSEPORT` for coexistence with other MNDP listeners. Default true. */
|
|
77
|
+
/** Enable `SO_REUSEPORT` for coexistence with other MNDP listeners. Default true except on Windows. */
|
|
73
78
|
reusePort?: boolean;
|
|
74
79
|
/** Called once with the actually-bound port (useful with `port: 0`). */
|
|
75
80
|
onBound?: (port: number) => void;
|
|
@@ -180,7 +185,7 @@ export function listenMndp(
|
|
|
180
185
|
const socket = createSocket({
|
|
181
186
|
type: "udp4",
|
|
182
187
|
reuseAddr: options.reuseAddr ?? true,
|
|
183
|
-
reusePort: options.reusePort ??
|
|
188
|
+
reusePort: options.reusePort ?? defaultMndpReusePort(),
|
|
184
189
|
});
|
|
185
190
|
let settled = false;
|
|
186
191
|
let boundPort = requestedPort;
|