@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
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device GPS: lat/lon/altitude parsing, validation, and alias canonicalization
|
|
3
|
+
* (centrs issue #146, Slice 1 — storage + validation + envelope + exact
|
|
4
|
+
* `--where`).
|
|
5
|
+
*
|
|
6
|
+
* Coordinates are stored as comment-kv facts (`lat`, `lon`, `altitude`,
|
|
7
|
+
* `altitude-type`) — see `commands/devices/README.md` (Location / GPS) and
|
|
8
|
+
* `docs/CONSTITUTION.md`. This module owns:
|
|
9
|
+
*
|
|
10
|
+
* - alias canonicalization ({@link canonicalizeGeoKey}: `lng`/`longitude`/
|
|
11
|
+
* `long` -> `lon`, `latitude` -> `lat`, `alt`/`ele`/`elevation` ->
|
|
12
|
+
* `altitude`, and mixed-case `altitude-type`) so the canonical key is what
|
|
13
|
+
* lands in the comment and the envelope,
|
|
14
|
+
* - range/enum validation (`input/invalid-coordinate`,
|
|
15
|
+
* `input/invalid-altitude`), and
|
|
16
|
+
* - the `--gps <lat>,<lon>[,<altitude>[,<altitude-type>]]` combined-flag
|
|
17
|
+
* shape (`input/incomplete-gps` on wrong arity).
|
|
18
|
+
*
|
|
19
|
+
* **Coordinate order is lat-first everywhere** (ISO 6709 / EPSG:4326
|
|
20
|
+
* authority order, matching Google Maps / Leaflet / most GPS devices) — the
|
|
21
|
+
* opposite of GeoJSON's lon-first order (RFC 7946 itself calls swapping them
|
|
22
|
+
* "the most common GeoJSON bug"). A lat-first `--gps 37.77,-122.41` is what an
|
|
23
|
+
* agent guesses from the flag name; GeoJSON's lon-first order is deliberately
|
|
24
|
+
* out of scope for this bare-CSV surface.
|
|
25
|
+
*
|
|
26
|
+
* Values are stored **verbatim as typed** (validated, not reformatted) so an
|
|
27
|
+
* exact `--where lat=<value>` still matches what was written; `altitude-type`
|
|
28
|
+
* is the one exception — it is a closed two-value enum, so it is normalized to
|
|
29
|
+
* upper case (`msl` -> `MSL`) before storage. Pairing (`lat`/`lon` set
|
|
30
|
+
* together) needs both the incoming updates and the record's prior comment, so
|
|
31
|
+
* it is validated in `src/devices.ts` (`validateCommentKvUpdates`), which
|
|
32
|
+
* calls the pure primitives here; this module has no CDB/record awareness.
|
|
33
|
+
*
|
|
34
|
+
* Slice 2 (`--near`/`--bbox`, haversine/bbox query predicates) lives at the
|
|
35
|
+
* bottom of this file; it is pure geo math over parsed {@link DeviceLocation}s,
|
|
36
|
+
* with the CDB-entry composition in `src/resolver/facts.ts`.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { CentrsError } from "../errors.ts";
|
|
40
|
+
|
|
41
|
+
/** Vertical datum for a stored altitude. */
|
|
42
|
+
export type AltitudeType = "MSL" | "AGL";
|
|
43
|
+
|
|
44
|
+
/** A device's parsed GPS location, read from comment-kv facts. */
|
|
45
|
+
export interface DeviceLocation {
|
|
46
|
+
lat: number;
|
|
47
|
+
lon: number;
|
|
48
|
+
altitude?: number;
|
|
49
|
+
altitudeType?: AltitudeType;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Alias -> canonical key map for the geo comment-kv fields. Applied in ONE
|
|
54
|
+
* place — {@link canonicalizeGeoKey} — so both `--flag` lowering and bare `k=v`
|
|
55
|
+
* positionals agree on the canonical key that lands in storage. `altitude` stays
|
|
56
|
+
* the canonical field (the ISO 6709 / EPSG / IETF `geo:` / W3C-Geolocation term);
|
|
57
|
+
* `elevation`/`ele` are GPX-muscle-memory aliases only, never a rename. Every
|
|
58
|
+
* canonical key also maps to itself so a mixed-case bare token (`LAT=`,
|
|
59
|
+
* `ALTITUDE-TYPE=`) canonicalizes by case too.
|
|
60
|
+
*/
|
|
61
|
+
const GEO_KEY_ALIASES: Readonly<
|
|
62
|
+
Record<string, "lat" | "lon" | "altitude" | "altitude-type">
|
|
63
|
+
> = {
|
|
64
|
+
lat: "lat",
|
|
65
|
+
latitude: "lat",
|
|
66
|
+
lon: "lon",
|
|
67
|
+
lng: "lon",
|
|
68
|
+
longitude: "lon",
|
|
69
|
+
long: "lon",
|
|
70
|
+
altitude: "altitude",
|
|
71
|
+
alt: "altitude",
|
|
72
|
+
ele: "altitude",
|
|
73
|
+
elevation: "altitude",
|
|
74
|
+
"altitude-type": "altitude-type",
|
|
75
|
+
"alt-type": "altitude-type",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Canonicalize a comment-kv key alias (case-insensitive) to its stored form.
|
|
80
|
+
* A key with no known alias is returned unchanged (original casing preserved),
|
|
81
|
+
* so this is safe to apply to every bare `k=v` token, not just geo ones.
|
|
82
|
+
*/
|
|
83
|
+
export function canonicalizeGeoKey(key: string): string {
|
|
84
|
+
return GEO_KEY_ALIASES[key.toLowerCase()] ?? key;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a **strict decimal** number string: an optional leading `-`, one or more
|
|
89
|
+
* digits, and an optional dotted fraction — the decimal-degrees / meters grammar
|
|
90
|
+
* the GPS fields document (`docs/CONSTITUTION.md`). Returns `undefined` for blank
|
|
91
|
+
* input and for forms a bare `Number()` would silently accept but the spec does
|
|
92
|
+
* not: hex (`0x10`), scientific (`1e2`), or a leading `+`. Also rejects a value
|
|
93
|
+
* that overflows to `±Infinity` (e.g. a 400-digit integer) — `parseAltitude` has
|
|
94
|
+
* no range clamp, so the finite guard is what keeps `Infinity` out of it.
|
|
95
|
+
*/
|
|
96
|
+
function parseDecimal(value: string): number | undefined {
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
if (!/^-?[0-9]+(?:\.[0-9]+)?$/.test(trimmed)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const parsed = Number(trimmed);
|
|
102
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const COORDINATE_RANGE: Readonly<
|
|
106
|
+
Record<"lat" | "lon", { min: number; max: number }>
|
|
107
|
+
> = {
|
|
108
|
+
lat: { min: -90, max: 90 },
|
|
109
|
+
lon: { min: -180, max: 180 },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate a latitude (`kind: "lat"`, range `[-90, 90]`) or longitude
|
|
114
|
+
* (`kind: "lon"`, range `[-180, 180]`) string, throwing
|
|
115
|
+
* `input/invalid-coordinate` on NaN or out-of-range. Returns the parsed
|
|
116
|
+
* number; storage keeps the original string verbatim (see module doc-comment).
|
|
117
|
+
*/
|
|
118
|
+
export function parseLatLon(value: string, kind: "lat" | "lon"): number {
|
|
119
|
+
const range = COORDINATE_RANGE[kind];
|
|
120
|
+
const parsed = parseDecimal(value);
|
|
121
|
+
if (parsed === undefined || parsed < range.min || parsed > range.max) {
|
|
122
|
+
const label = kind === "lat" ? "latitude" : "longitude";
|
|
123
|
+
throw new CentrsError({
|
|
124
|
+
code: "input/invalid-coordinate",
|
|
125
|
+
summary: `Invalid ${label} "${value}"; must be a number between ${range.min} and ${range.max}.`,
|
|
126
|
+
remediation:
|
|
127
|
+
kind === "lat"
|
|
128
|
+
? "Pass a decimal-degrees latitude in [-90, 90], e.g. --lat 37.7749."
|
|
129
|
+
: "Pass a decimal-degrees longitude in [-180, 180], e.g. --lon -122.4194.",
|
|
130
|
+
context: { key: kind, value },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Validate an altitude (meters, may be negative), throwing `input/invalid-altitude` on NaN. */
|
|
137
|
+
export function parseAltitude(value: string): number {
|
|
138
|
+
const parsed = parseDecimal(value);
|
|
139
|
+
if (parsed === undefined) {
|
|
140
|
+
throw new CentrsError({
|
|
141
|
+
code: "input/invalid-altitude",
|
|
142
|
+
summary: `Invalid altitude "${value}"; must be a number (meters).`,
|
|
143
|
+
remediation:
|
|
144
|
+
"Pass altitude in meters, e.g. --altitude 120 (negative values are allowed below sea level).",
|
|
145
|
+
context: { key: "altitude", value },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate + canonicalize an altitude-type value (case-insensitive), throwing
|
|
153
|
+
* `input/invalid-altitude` for anything other than `MSL`/`AGL`. Returns the
|
|
154
|
+
* upper-cased canonical form — the one geo field normalized for storage, since
|
|
155
|
+
* it is a closed two-value enum rather than freeform/numeric.
|
|
156
|
+
*/
|
|
157
|
+
export function parseAltitudeType(value: string): AltitudeType {
|
|
158
|
+
const upper = value.trim().toUpperCase();
|
|
159
|
+
if (upper === "MSL" || upper === "AGL") {
|
|
160
|
+
return upper;
|
|
161
|
+
}
|
|
162
|
+
throw new CentrsError({
|
|
163
|
+
code: "input/invalid-altitude",
|
|
164
|
+
summary: `Invalid altitude-type "${value}"; must be MSL or AGL.`,
|
|
165
|
+
remediation:
|
|
166
|
+
"Use --altitude-type MSL (mean sea level, the default) or AGL (above ground level).",
|
|
167
|
+
context: { key: "altitude-type", value },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* {@link parseGpsTuple}'s validated result: parsed numbers plus the original,
|
|
173
|
+
* untouched substrings for verbatim-as-typed storage.
|
|
174
|
+
*/
|
|
175
|
+
export interface ParsedGpsTuple {
|
|
176
|
+
lat: number;
|
|
177
|
+
lon: number;
|
|
178
|
+
altitude?: number;
|
|
179
|
+
altitudeType?: AltitudeType;
|
|
180
|
+
/**
|
|
181
|
+
* The original (trimmed) substrings, for verbatim storage. `altitudeType`
|
|
182
|
+
* here is already canonical-cased since it is a closed enum, not a
|
|
183
|
+
* freeform/numeric value.
|
|
184
|
+
*/
|
|
185
|
+
raw: {
|
|
186
|
+
lat: string;
|
|
187
|
+
lon: string;
|
|
188
|
+
altitude?: string;
|
|
189
|
+
altitudeType?: string;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse the `--gps <lat>,<lon>[,<altitude>[,<altitude-type>]]` combined-flag
|
|
195
|
+
* shape (lat-first — see module doc-comment). Requires 2-4 non-empty
|
|
196
|
+
* comma-separated parts, throwing `input/incomplete-gps` otherwise. A missing
|
|
197
|
+
* altitude-type in the 3-part form defaults to `MSL`.
|
|
198
|
+
*/
|
|
199
|
+
export function parseGpsTuple(value: string): ParsedGpsTuple {
|
|
200
|
+
const parts = value.split(",").map((part) => part.trim());
|
|
201
|
+
if (
|
|
202
|
+
parts.length < 2 ||
|
|
203
|
+
parts.length > 4 ||
|
|
204
|
+
parts.some((part) => part.length === 0)
|
|
205
|
+
) {
|
|
206
|
+
throw new CentrsError({
|
|
207
|
+
code: "input/incomplete-gps",
|
|
208
|
+
summary: `--gps "${value}" must be <lat>,<lon>[,<altitude>[,<altitude-type>]].`,
|
|
209
|
+
remediation:
|
|
210
|
+
"Pass at least lat,lon (e.g. --gps 37.7749,-122.4194), optionally followed by altitude and MSL|AGL.",
|
|
211
|
+
context: { gps: value },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const [rawLat, rawLon, rawAltitude, rawAltitudeType] = parts as [
|
|
215
|
+
string,
|
|
216
|
+
string,
|
|
217
|
+
string?,
|
|
218
|
+
string?,
|
|
219
|
+
];
|
|
220
|
+
const lat = parseLatLon(rawLat, "lat");
|
|
221
|
+
const lon = parseLatLon(rawLon, "lon");
|
|
222
|
+
const result: ParsedGpsTuple = {
|
|
223
|
+
lat,
|
|
224
|
+
lon,
|
|
225
|
+
raw: { lat: rawLat, lon: rawLon },
|
|
226
|
+
};
|
|
227
|
+
if (rawAltitude !== undefined) {
|
|
228
|
+
result.altitude = parseAltitude(rawAltitude);
|
|
229
|
+
result.raw.altitude = rawAltitude;
|
|
230
|
+
result.altitudeType =
|
|
231
|
+
rawAltitudeType !== undefined
|
|
232
|
+
? parseAltitudeType(rawAltitudeType)
|
|
233
|
+
: "MSL";
|
|
234
|
+
result.raw.altitudeType = result.altitudeType;
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read a device's location from {@link parseRawCommentFacts}-shaped output
|
|
241
|
+
* (every `k=v` comment token, unfiltered — see `src/resolver/comment-kv.ts`).
|
|
242
|
+
* Lenient by design: a missing or malformed `lat`/`lon` (e.g. a hand-edited
|
|
243
|
+
* comment) yields `undefined` rather than throwing — this is a read path, and
|
|
244
|
+
* write-time validation (`validateCommentKvUpdates` in `src/devices.ts`) is
|
|
245
|
+
* what keeps stored values well-formed. `altitudeType` defaults to `MSL` when
|
|
246
|
+
* `altitude` is present but no recognized `altitude-type` fact is stored.
|
|
247
|
+
*/
|
|
248
|
+
export function deviceLocation(
|
|
249
|
+
facts: Record<string, string>,
|
|
250
|
+
): DeviceLocation | undefined {
|
|
251
|
+
const rawLat = facts["lat"];
|
|
252
|
+
const rawLon = facts["lon"];
|
|
253
|
+
if (rawLat === undefined || rawLon === undefined) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const lat = parseDecimal(rawLat);
|
|
257
|
+
const lon = parseDecimal(rawLon);
|
|
258
|
+
if (
|
|
259
|
+
lat === undefined ||
|
|
260
|
+
lon === undefined ||
|
|
261
|
+
lat < COORDINATE_RANGE.lat.min ||
|
|
262
|
+
lat > COORDINATE_RANGE.lat.max ||
|
|
263
|
+
lon < COORDINATE_RANGE.lon.min ||
|
|
264
|
+
lon > COORDINATE_RANGE.lon.max
|
|
265
|
+
) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
const location: DeviceLocation = { lat, lon };
|
|
269
|
+
const rawAltitude = facts["altitude"];
|
|
270
|
+
if (rawAltitude !== undefined) {
|
|
271
|
+
const altitude = parseDecimal(rawAltitude);
|
|
272
|
+
if (altitude !== undefined) {
|
|
273
|
+
location.altitude = altitude;
|
|
274
|
+
const rawType = facts["altitude-type"];
|
|
275
|
+
const upper = rawType?.trim().toUpperCase();
|
|
276
|
+
location.altitudeType =
|
|
277
|
+
upper === "MSL" || upper === "AGL" ? upper : "MSL";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return location;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Slice 2: geo query predicates (`--near` / `--bbox`) ---------------------
|
|
284
|
+
//
|
|
285
|
+
// Pure geo math + predicate parsing. These operate on already-parsed
|
|
286
|
+
// {@link DeviceLocation}s (altitude is ignored — the query is 2-D lat/lon). The
|
|
287
|
+
// CDB-entry composition (read a record's location, union/AND into a selection)
|
|
288
|
+
// lives in `src/resolver/facts.ts` and the callers; this file stays free of any
|
|
289
|
+
// CDB/record awareness.
|
|
290
|
+
|
|
291
|
+
/** A `--near <lat>,<lon>,<radius>` predicate, radius already resolved to meters. */
|
|
292
|
+
export interface NearPredicate {
|
|
293
|
+
lat: number;
|
|
294
|
+
lon: number;
|
|
295
|
+
radiusMeters: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A `--bbox <south>,<west>,<north>,<east>` predicate (lat-first, decimal degrees). */
|
|
299
|
+
export interface BboxPredicate {
|
|
300
|
+
south: number;
|
|
301
|
+
west: number;
|
|
302
|
+
north: number;
|
|
303
|
+
east: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Radius unit suffixes (Redis GEOSEARCH vocabulary), case-insensitive. A bare
|
|
308
|
+
* number with no suffix defaults to **km** (the common operator/agent unit for
|
|
309
|
+
* "devices within N of here").
|
|
310
|
+
*/
|
|
311
|
+
const RADIUS_UNIT_METERS: Readonly<Record<string, number>> = {
|
|
312
|
+
m: 1,
|
|
313
|
+
km: 1000,
|
|
314
|
+
mi: 1609.344,
|
|
315
|
+
ft: 0.3048,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Parse a radius like `50km` / `500m` / `10mi` / `2000ft` (or a bare number,
|
|
320
|
+
* defaulting to km) to meters. Throws `input/invalid-radius` on a malformed
|
|
321
|
+
* value, an unknown unit, or a negative magnitude.
|
|
322
|
+
*/
|
|
323
|
+
export function parseRadius(value: string): number {
|
|
324
|
+
// Unambiguous number grammar (integer part then an optional dotted fraction),
|
|
325
|
+
// so there are no adjacent same-class quantifiers to backtrack over — avoids
|
|
326
|
+
// the polynomial-ReDoS a `[0-9]*\.?[0-9]+` shape would allow on long digit runs.
|
|
327
|
+
const match = /^(-?[0-9]+(?:\.[0-9]+)?)\s*([a-z]+)?$/i.exec(value.trim());
|
|
328
|
+
const unitKey = match?.[2]?.toLowerCase() ?? "km";
|
|
329
|
+
const perUnit = RADIUS_UNIT_METERS[unitKey];
|
|
330
|
+
const magnitude = match ? Number(match[1]) : Number.NaN;
|
|
331
|
+
if (!match || perUnit === undefined || !Number.isFinite(magnitude)) {
|
|
332
|
+
throw new CentrsError({
|
|
333
|
+
code: "input/invalid-radius",
|
|
334
|
+
summary: `Invalid radius "${value}"; expected a number with an optional m/km/mi/ft suffix.`,
|
|
335
|
+
remediation:
|
|
336
|
+
"Pass a positive radius like `50km`, `500m`, `10mi`, or `2000ft` (a bare number is kilometers).",
|
|
337
|
+
context: { value },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const meters = magnitude * perUnit;
|
|
341
|
+
if (meters < 0) {
|
|
342
|
+
throw new CentrsError({
|
|
343
|
+
code: "input/invalid-radius",
|
|
344
|
+
summary: `Invalid radius "${value}"; must not be negative.`,
|
|
345
|
+
remediation: "Pass a positive radius, e.g. `50km`.",
|
|
346
|
+
context: { value },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return meters;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** IUGG mean Earth radius (meters) — the standard sphere for haversine. */
|
|
353
|
+
const EARTH_RADIUS_METERS = 6_371_008.8;
|
|
354
|
+
|
|
355
|
+
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Great-circle distance in meters between two lat/lon points (haversine on a
|
|
359
|
+
* sphere of {@link EARTH_RADIUS_METERS}). Accurate to well under 1% for the
|
|
360
|
+
* fleet-proximity distances centrs cares about; not a geodesic on the ellipsoid.
|
|
361
|
+
*/
|
|
362
|
+
export function haversineMeters(
|
|
363
|
+
a: { lat: number; lon: number },
|
|
364
|
+
b: { lat: number; lon: number },
|
|
365
|
+
): number {
|
|
366
|
+
const dLat = toRadians(b.lat - a.lat);
|
|
367
|
+
const dLon = toRadians(b.lon - a.lon);
|
|
368
|
+
const lat1 = toRadians(a.lat);
|
|
369
|
+
const lat2 = toRadians(b.lat);
|
|
370
|
+
const h =
|
|
371
|
+
Math.sin(dLat / 2) ** 2 +
|
|
372
|
+
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
|
373
|
+
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Parse `--near <lat>,<lon>,<radius>` (lat-first — see module doc-comment).
|
|
378
|
+
* Requires exactly three non-empty comma parts (`input/invalid-command` on wrong
|
|
379
|
+
* arity); the radius is resolved to meters ({@link parseRadius}).
|
|
380
|
+
*/
|
|
381
|
+
export function parseNear(value: string): NearPredicate {
|
|
382
|
+
const parts = value.split(",").map((part) => part.trim());
|
|
383
|
+
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
|
384
|
+
throw new CentrsError({
|
|
385
|
+
code: "input/invalid-command",
|
|
386
|
+
summary: `--near "${value}" must be <lat>,<lon>,<radius>.`,
|
|
387
|
+
remediation:
|
|
388
|
+
"Pass a center and radius, e.g. --near 37.7749,-122.4194,50km (lat,lon first, radius last).",
|
|
389
|
+
context: { near: value },
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const [rawLat, rawLon, rawRadius] = parts as [string, string, string];
|
|
393
|
+
return {
|
|
394
|
+
lat: parseLatLon(rawLat, "lat"),
|
|
395
|
+
lon: parseLatLon(rawLon, "lon"),
|
|
396
|
+
radiusMeters: parseRadius(rawRadius),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** True when `loc` is within `near`'s radius (great-circle). Altitude ignored. */
|
|
401
|
+
export function matchesNear(loc: DeviceLocation, near: NearPredicate): boolean {
|
|
402
|
+
return haversineMeters(loc, near) <= near.radiusMeters;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Parse `--bbox <south>,<west>,<north>,<east>` (= minLat,minLon,maxLat,maxLon,
|
|
407
|
+
* lat-first). Requires four non-empty parts and `south <= north`,
|
|
408
|
+
* `west <= east` (no antimeridian wrap in v1) — else `input/invalid-bbox`.
|
|
409
|
+
*/
|
|
410
|
+
export function parseBbox(value: string): BboxPredicate {
|
|
411
|
+
const parts = value.split(",").map((part) => part.trim());
|
|
412
|
+
if (parts.length !== 4 || parts.some((part) => part.length === 0)) {
|
|
413
|
+
throw new CentrsError({
|
|
414
|
+
code: "input/invalid-bbox",
|
|
415
|
+
summary: `--bbox "${value}" must be <south>,<west>,<north>,<east>.`,
|
|
416
|
+
remediation:
|
|
417
|
+
"Pass a lat-first box, e.g. --bbox 37.70,-122.52,37.83,-122.35 (south,west,north,east).",
|
|
418
|
+
context: { bbox: value },
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
const [rawSouth, rawWest, rawNorth, rawEast] = parts as [
|
|
422
|
+
string,
|
|
423
|
+
string,
|
|
424
|
+
string,
|
|
425
|
+
string,
|
|
426
|
+
];
|
|
427
|
+
const box: BboxPredicate = {
|
|
428
|
+
south: parseLatLon(rawSouth, "lat"),
|
|
429
|
+
west: parseLatLon(rawWest, "lon"),
|
|
430
|
+
north: parseLatLon(rawNorth, "lat"),
|
|
431
|
+
east: parseLatLon(rawEast, "lon"),
|
|
432
|
+
};
|
|
433
|
+
if (box.south > box.north || box.west > box.east) {
|
|
434
|
+
throw new CentrsError({
|
|
435
|
+
code: "input/invalid-bbox",
|
|
436
|
+
summary: `--bbox "${value}" must have south <= north and west <= east.`,
|
|
437
|
+
remediation:
|
|
438
|
+
"Order corners south,west,north,east; an antimeridian-crossing box is not supported.",
|
|
439
|
+
context: { bbox: value },
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return box;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** True when `loc` falls inside the axis-aligned `box` (inclusive edges). */
|
|
446
|
+
export function matchesBbox(loc: DeviceLocation, box: BboxPredicate): boolean {
|
|
447
|
+
return (
|
|
448
|
+
loc.lat >= box.south &&
|
|
449
|
+
loc.lat <= box.north &&
|
|
450
|
+
loc.lon >= box.west &&
|
|
451
|
+
loc.lon <= box.east
|
|
452
|
+
);
|
|
453
|
+
}
|
package/src/resolver/index.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* Layers:
|
|
10
10
|
* - `./settings.ts` — precedence primitives (default < config < comment-kv <
|
|
11
11
|
* env < cli) and source coercion to the core envelope union.
|
|
12
|
+
* - `./config-file.ts` — the `config` tier itself: loads `centrs.env`
|
|
13
|
+
* (`commands/settings/README.md`) into the flat map every command passes
|
|
14
|
+
* as the `config` argument to `./settings.ts`'s resolvers.
|
|
12
15
|
* - `./cdb.ts` — CDB identity + comment-kv parse/coerce/provenance.
|
|
13
16
|
* - `./target.ts` — host/port/baseUrl identity + credentials + per-field
|
|
14
17
|
* provenance map.
|
|
@@ -23,21 +26,54 @@ export {
|
|
|
23
26
|
type CdbResolveInput,
|
|
24
27
|
type CommentKvOverrides,
|
|
25
28
|
coerceCommentKv,
|
|
29
|
+
DEFAULT_RECORD_TARGET,
|
|
26
30
|
expandCdbGroup,
|
|
31
|
+
identityFromComment,
|
|
32
|
+
macFromComment,
|
|
27
33
|
type ResolverWarning,
|
|
34
|
+
resolutionFromEntry,
|
|
28
35
|
resolveCdb,
|
|
29
36
|
} from "./cdb.ts";
|
|
30
37
|
export {
|
|
31
38
|
applyCommentKv,
|
|
39
|
+
type CommentKvGeoKey,
|
|
32
40
|
type CommentKvKey,
|
|
33
41
|
type CommentKvResult,
|
|
34
42
|
type CommentKvUpdate,
|
|
35
43
|
type CommentKvWarning,
|
|
36
44
|
commentKvAllowlist,
|
|
45
|
+
commentKvGeoKeys,
|
|
37
46
|
commentKvReservedKeys,
|
|
38
47
|
parseCommentKv,
|
|
48
|
+
parseRawCommentFacts,
|
|
39
49
|
renderCommentKvToken,
|
|
40
50
|
} from "./comment-kv.ts";
|
|
51
|
+
export {
|
|
52
|
+
defaultSettingsPath,
|
|
53
|
+
loadEnvFileDefaults,
|
|
54
|
+
parseEnvFileDefaults,
|
|
55
|
+
readSettingsFileRaw,
|
|
56
|
+
type SettingsFileContents,
|
|
57
|
+
} from "./config-file.ts";
|
|
58
|
+
export {
|
|
59
|
+
type AltitudeType,
|
|
60
|
+
type BboxPredicate,
|
|
61
|
+
canonicalizeGeoKey,
|
|
62
|
+
type DeviceLocation,
|
|
63
|
+
deviceLocation,
|
|
64
|
+
haversineMeters,
|
|
65
|
+
matchesBbox,
|
|
66
|
+
matchesNear,
|
|
67
|
+
type NearPredicate,
|
|
68
|
+
type ParsedGpsTuple,
|
|
69
|
+
parseAltitude,
|
|
70
|
+
parseAltitudeType,
|
|
71
|
+
parseBbox,
|
|
72
|
+
parseGpsTuple,
|
|
73
|
+
parseLatLon,
|
|
74
|
+
parseNear,
|
|
75
|
+
parseRadius,
|
|
76
|
+
} from "./geo.ts";
|
|
41
77
|
export {
|
|
42
78
|
type ArpEntry,
|
|
43
79
|
effectiveHostCandidate,
|
|
@@ -51,10 +87,39 @@ export {
|
|
|
51
87
|
resolveMacViaArp,
|
|
52
88
|
unresolvedMacError,
|
|
53
89
|
} from "./mac.ts";
|
|
90
|
+
export {
|
|
91
|
+
assertNoQuickchrOverrideConflict,
|
|
92
|
+
type QuickchrConnection,
|
|
93
|
+
type QuickchrResolution,
|
|
94
|
+
quickchrConnection,
|
|
95
|
+
resolveQuickchrTarget,
|
|
96
|
+
SUPPORTED_DESCRIPTOR_VERSION,
|
|
97
|
+
} from "./quickchr-provider.ts";
|
|
98
|
+
export {
|
|
99
|
+
expandSelection,
|
|
100
|
+
isDefaultRecordTarget,
|
|
101
|
+
type SelectionExpansion,
|
|
102
|
+
type SelectionMember,
|
|
103
|
+
type SelectionResolveInput,
|
|
104
|
+
type SelectionWhereClause,
|
|
105
|
+
type TargetSelection,
|
|
106
|
+
} from "./selection.ts";
|
|
107
|
+
export {
|
|
108
|
+
type AnyServiceEndpoint,
|
|
109
|
+
isEndpointAvailable,
|
|
110
|
+
type ServiceEndpoint,
|
|
111
|
+
type ServiceEndpointAuth,
|
|
112
|
+
type ServiceEndpointMap,
|
|
113
|
+
type ServiceEndpointSource,
|
|
114
|
+
type SshEndpointAuth,
|
|
115
|
+
type SshServiceEndpoint,
|
|
116
|
+
type UnavailableEndpoint,
|
|
117
|
+
} from "./service-endpoint.ts";
|
|
54
118
|
export {
|
|
55
119
|
type CommentKvLayer,
|
|
56
120
|
parseBoolean,
|
|
57
121
|
parseDuration,
|
|
122
|
+
REFUSED_CONFIG_ENV_KEYS,
|
|
58
123
|
type ResolvedSetting,
|
|
59
124
|
type ResolverSettingSource,
|
|
60
125
|
type ResolverSettingSourceKind,
|
package/src/resolver/mac.ts
CHANGED
|
@@ -132,17 +132,25 @@ export function parseResolvePolicy(value: string | undefined): ResolvePolicy {
|
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
134
|
* Compute the effective host candidate using the same precedence as
|
|
135
|
-
* {@link resolveTarget}: explicit host > `CENTRS_HOST` >
|
|
136
|
-
* `<router>` positional.
|
|
135
|
+
* {@link resolveTarget}: explicit host > `CENTRS_HOST` > config-file
|
|
136
|
+
* `CENTRS_HOST` > CDB target > the bare `<router>` positional. Must stay in
|
|
137
|
+
* sync with `resolveTarget`'s own `CENTRS_HOST` ladder — this exists
|
|
138
|
+
* separately because MAC detection has to happen before `resolveTarget`
|
|
139
|
+
* builds the final `ResolvedTarget`, not because the precedence differs.
|
|
137
140
|
*/
|
|
138
141
|
export function effectiveHostCandidate(opts: {
|
|
139
142
|
host?: string;
|
|
140
143
|
targetInput?: string;
|
|
141
144
|
cdbTarget?: string;
|
|
142
145
|
env: Record<string, string | undefined>;
|
|
146
|
+
config?: Record<string, string | undefined>;
|
|
143
147
|
}): string | undefined {
|
|
144
148
|
const candidate =
|
|
145
|
-
opts.host ??
|
|
149
|
+
opts.host ??
|
|
150
|
+
opts.env["CENTRS_HOST"] ??
|
|
151
|
+
opts.config?.["CENTRS_HOST"] ??
|
|
152
|
+
opts.cdbTarget ??
|
|
153
|
+
opts.targetInput;
|
|
146
154
|
return candidate?.trim() ? candidate.trim() : undefined;
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -159,6 +167,7 @@ export async function resolveMacTarget(opts: {
|
|
|
159
167
|
targetInput?: string;
|
|
160
168
|
cdbTarget?: string;
|
|
161
169
|
env: Record<string, string | undefined>;
|
|
170
|
+
config?: Record<string, string | undefined>;
|
|
162
171
|
policy: ResolvePolicy;
|
|
163
172
|
operation: "retrieve" | "execute" | "terminal";
|
|
164
173
|
runArp?: (cmd: string[]) => Promise<string>;
|