@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.
Files changed (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. 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
+ }
@@ -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,
@@ -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` > CDB target > the bare
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 ?? opts.env["CENTRS_HOST"] ?? opts.cdbTarget ?? opts.targetInput;
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>;