@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,390 @@
1
+ # check — examples
2
+
3
+ Each numbered example is an executable spec. Once `check` is implemented, the
4
+ integration test under `test/integration/check.test.ts` will run the CHR-backed
5
+ examples against a CHR booted by `@tikoci/quickchr`, and the offline examples
6
+ (flag parsing, `--list-checks`, fixture-backed `--save`) will run under
7
+ `test/unit/check.test.ts` — one assertion per example (example N ↔ assertion N).
8
+ Until then these are the **target**: if a line here is not exercised by a test
9
+ when the code lands, the test file is wrong; if a line passes only with
10
+ `--validate=false`, the **implementation** is wrong (see `docs/CONSTITUTION.md`).
11
+
12
+ These examples are the **target** for `CHR-passed`; the cells are `designed`
13
+ today, so nothing here is green yet.
14
+
15
+ `$R` is `<host>:<rest-port>` resolved by quickchr. `$U` / `$P` are CHR
16
+ credentials from the harness. Envelope-asserting examples pass `--json`.
17
+
18
+ ## Smart battery (default)
19
+
20
+ ### 1. Bare check runs the smart battery
21
+
22
+ ```bash
23
+ centrs check $R --username $U --password $P --json
24
+ ```
25
+
26
+ Envelope: `ok: true` (a healthy CHR raises warnings/tips at most, not errors),
27
+ `data.checks[]` includes at least `reach`, `services`, `resource`, `cloud`,
28
+ `dns`; each entry has `ran: true` and a `metadata` object. `data.profile`
29
+ carries `board`, `arch`, `version`, `channel`. `meta.via` is the transport the
30
+ battery rode.
31
+
32
+ ### 2. Profile block is populated
33
+
34
+ ```bash
35
+ centrs check $R --username $U --password $P --json
36
+ ```
37
+
38
+ Assert `data.profile.ports` maps `rest-api`/`native-api` to the live
39
+ `/ip/service` ports, and `data.profile.reachable` lists the transports that
40
+ answered. (Shape is additive/non-normative — assert presence, not an exact key
41
+ set.)
42
+
43
+ ### 3. No credentials → reach only, tip for the rest
44
+
45
+ ```bash
46
+ centrs check $R --json
47
+ ```
48
+
49
+ Envelope: `ok: true`; `data.checks[]` has `reach` (`ran: true`) while the health
50
+ checks report `ran: false` with `skipReason` naming missing credentials, and a
51
+ `tip/reach/credentials-needed` is present.
52
+
53
+ ## Selection and presets
54
+
55
+ ### 4. `--preset reach` is the fast slice
56
+
57
+ ```bash
58
+ centrs check $R --username $U --password $P --preset reach --json
59
+ ```
60
+
61
+ Only the `reach` check ran; no health-phase reads were issued. (This is the slice
62
+ `discover`'s IP-scan iterates.)
63
+
64
+ ### 5. `--checks` runs an explicit subset (+ dependencies)
65
+
66
+ ```bash
67
+ centrs check $R --username $U --password $P --checks services,resource --json
68
+ ```
69
+
70
+ `data.checks[]` contains exactly `services` and `resource` (and any `dependsOn`),
71
+ nothing else.
72
+
73
+ ### 6. `--skip` subtracts from the default
74
+
75
+ ```bash
76
+ centrs check $R --username $U --password $P --skip dns --json
77
+ ```
78
+
79
+ The default battery ran except `dns` (absent from `data.checks[]`).
80
+
81
+ ### 7. `--list-checks` explains the catalog (offline)
82
+
83
+ ```bash
84
+ centrs check --list-checks --json
85
+ ```
86
+
87
+ No network IO. `data` lists every registered check with `id`, `title`,
88
+ `description`, `tags`, `phase`, `default`. Exit `0`.
89
+
90
+ ## Findings and reaching green
91
+
92
+ ### 8. A risky service raises a warning
93
+
94
+ Precondition: telnet is enabled on the CHR (stock CHR 7.23.1 has it enabled;
95
+ if the fixture hardened services first, run `/ip/service enable telnet`).
96
+
97
+ ```bash
98
+ centrs check $R --username $U --password $P --checks services --json
99
+ ```
100
+
101
+ `data.checks[].findings` includes a `check/services/telnet-enabled` warning with
102
+ a `fix`. Top-level `warnings[]` carries the same code (roll-up).
103
+
104
+ ### 9. `--ignore` suppresses a known finding back to green
105
+
106
+ ```bash
107
+ centrs check $R --username $U --password $P --checks services \
108
+ --ignore check/services/telnet-enabled --json
109
+ ```
110
+
111
+ The warning no longer appears in `findings[]` or `warnings[]`; `ok: true`.
112
+
113
+ ### 10. `--fail-on warning` gates the exit code, not `ok`
114
+
115
+ ```bash
116
+ centrs check $R --username $U --password $P --checks services --fail-on warning --json
117
+ ```
118
+
119
+ With telnet enabled: the run stays **`ok: true`** with the full `data` present and
120
+ `data.verdict = "warn"`, but the process **exit code is `2`** (reachable, over the
121
+ chosen bar). Re-running with `--ignore check/services/telnet-enabled` gives
122
+ `data.verdict = "pass"` and exit `0`. (At the default `--fail-on error` the same
123
+ warning is a footnote — exit `0` — see example 19.)
124
+
125
+ ## Capability pre-flight and transport
126
+
127
+ ### 11. An unsupported path is skipped, not errored
128
+
129
+ Simulated against a device/version where a check's `requires` path is absent
130
+ (fixture or older CHR).
131
+
132
+ ```bash
133
+ centrs check $R --username $U --password $P --checks routerboard --json
134
+ ```
135
+
136
+ On CHR (no RouterBOARD) `routerboard` reports `ran: false`,
137
+ `skipReason` referencing capability/CHR — `ok` stays `true`, no raw protocol
138
+ error surfaces.
139
+
140
+ ### 12. `--via` pins the transport with no silent downgrade
141
+
142
+ ```bash
143
+ centrs check $R --username $U --password $P --via native-api --json
144
+ ```
145
+
146
+ `meta.via = native-api`; the battery rode native-api. A pin to a transport that
147
+ cannot serve the battery errors with a `transport/*` code (constitution: never
148
+ silently downgrade across `--via`).
149
+
150
+ ### 13. A slow check is bounded by `--check-timeout`
151
+
152
+ ```bash
153
+ centrs check $R --username $U --password $P --checks traffic --check-timeout 2s --json
154
+ ```
155
+
156
+ `traffic` (a ~10 s probe) is cut at 2 s: its entry carries a
157
+ `check/traffic/timed-out` finding and the run still returns rather than hanging.
158
+
159
+ ## Custom check script
160
+
161
+ ### 14. Custom scripts merge as synthetic checks (repeatable, `--yes`-gated)
162
+
163
+ Two policy scripts, one inline and one from a fixture the test will add (the path
164
+ shown is its planned location), both required to `:put` an
165
+ `{errors,warnings,tips,metadata}` JSON envelope. An unset channel is
166
+ `[:toarray ""]`, which RouterOS serializes as an empty array `[]` (not `[""]`) —
167
+ grounded on CHR 7.24rc1, where three `[:toarray ""]`-initialized channels
168
+ serialized as `[]`:
169
+
170
+ ```bash
171
+ centrs check $R --username $U --password $P --yes --json \
172
+ --check-script '{:put [:serialize to=json {"errors"=[:toarray ""];"warnings"=[:toarray ""];"tips"=[:toarray ""];"metadata"={"ipv6-disabled"=[/ipv6/settings/get disable-ipv6]}}]}' \
173
+ --check-script-file test/integration/fixtures/check/policy.rsc
174
+ ```
175
+
176
+ Each merges under its own `check/custom/<n>`. Without `--yes` the run errors
177
+ `usage/confirmation-required` (a custom script is a trusted, possibly-mutating
178
+ path); with it, the envelope carries a `check/custom-script/ran` audit
179
+ warning. A policy `error` in a script sets `data.verdict = "fail"` → exit `2` at
180
+ the default `--fail-on` (the run itself is `ok: true`; the battery is present).
181
+
182
+ ## `--save` (CDB reconcile, fixture-backed)
183
+
184
+ ### 15. `--save` writes the port + derived facts to the CDB
185
+
186
+ Fixture CDB with a record for `$R` lacking `port=`/derived facts.
187
+
188
+ ```bash
189
+ centrs check $R --username $U --password $P --save --yes --json
190
+ ```
191
+
192
+ The record gains `port=` for the resolved transport (where non-default),
193
+ `board`/`version`/`software-id`, and an `updated=` stamp, through the `devices`
194
+ write layer. RouterOS is unchanged, and the SNMP community is **not** persisted.
195
+ Without `--save`, an identical run writes nothing to the CDB.
196
+
197
+ ## Fan-out
198
+
199
+ ### 16. Fan-out across a group
200
+
201
+ Fixture CDB group `lab` with two reachable CHRs.
202
+
203
+ ```bash
204
+ centrs check --group lab --json
205
+ ```
206
+
207
+ Locked `FanoutData` envelope (`data = { summary, targets[] }`); `targets[]` in
208
+ record order; exit `0` all-ok, `2` partial, `1` all-failed. A `--save --group lab`
209
+ without `--yes` errors, naming the blast radius.
210
+
211
+ ## Reach signals and redaction
212
+
213
+ ### 17. Reach reports latency and ARP presence
214
+
215
+ ```bash
216
+ centrs check $R --username $U --password $P --preset reach --json
217
+ ```
218
+
219
+ `data.profile` carries a host→router connect latency (RTT, ms) and an ARP-presence
220
+ fact for the target; `data.checks[].reach.metadata` lists the transports that
221
+ answered. No health-phase reads were issued.
222
+
223
+ ### 18. Secrets are redacted from metadata
224
+
225
+ Precondition: an enabled SNMP community and `/ip/cloud` Back-To-Home on the CHR.
226
+
227
+ ```bash
228
+ centrs check $R --username $U --password $P --checks snmp,cloud --json
229
+ ```
230
+
231
+ `snmp` metadata reports version/scope/count but **omits the community string**;
232
+ `cloud` metadata keeps `dns-name`/`status` but **omits** `vpn-wireguard-client-config`
233
+ and the VPN keys. A full-envelope grep for the community string / WireGuard config
234
+ finds nothing.
235
+
236
+ ## Presets, selection, and progressive control
237
+
238
+ ### 19. Warnings do not fail the run at the default `--fail-on`
239
+
240
+ Precondition: telnet enabled (as example 8).
241
+
242
+ ```bash
243
+ centrs check $R --username $U --password $P --json
244
+ ```
245
+
246
+ A `services` warning is present, yet exit `0`: at the default `--fail-on error`,
247
+ `data.verdict = "warn"` is a footnote, not a failure. (Contrast example 10, which
248
+ raises the bar to `warning`.)
249
+
250
+ ### 20. `--preset exposure` is a findings rollup, not extra reads
251
+
252
+ ```bash
253
+ centrs check $R --username $U --password $P --preset exposure --json
254
+ ```
255
+
256
+ `data.checks[]` contains only `services`, `certificates`, `snmp` (the preset
257
+ forces its members, including the normally-triggered `certificates`); no other
258
+ check ran and no path outside those three was read.
259
+
260
+ ### 21. `--preset full` runs the opt-off checks
261
+
262
+ ```bash
263
+ centrs check $R --username $U --password $P --preset full --json
264
+ ```
265
+
266
+ `data.checks[]` additionally includes the `default: off` rows — `health`,
267
+ `packages`, `console`, `traffic`, `connections` — each `ran: true`.
268
+
269
+ ### 22. `--deep` forces triggered checks even when their predicate is quiet
270
+
271
+ ```bash
272
+ centrs check $R --username $U --password $P --deep --json
273
+ ```
274
+
275
+ On an idle CHR (CPU low, `www-ssl` fine) `cpu-detail` and `certificates` still
276
+ ran (`ran: true`), where a bare run leaves them untriggered.
277
+
278
+ ### 23. `--fast` suppresses progressive triggers
279
+
280
+ ```bash
281
+ centrs check $R --username $U --password $P --fast --json
282
+ ```
283
+
284
+ No `trig` check appears in `data.checks[]` regardless of what the default battery
285
+ flagged; only cheap default checks ran.
286
+
287
+ ### 24. `--tag` filters to tagged checks
288
+
289
+ ```bash
290
+ centrs check $R --username $U --password $P --tag hardware --json
291
+ ```
292
+
293
+ Only on-by-default checks carrying the `hardware` tag ran (e.g. `resource`,
294
+ `routerboard`); untagged checks are absent. (The tag taxonomy is provisional —
295
+ see README.)
296
+
297
+ ## Suppression
298
+
299
+ ### 25. `--ignore <checkId>` mutes findings but keeps metadata; `--skip` drops the check
300
+
301
+ ```bash
302
+ centrs check $R --username $U --password $P --checks services --ignore services --json
303
+ ```
304
+
305
+ `services` still appears in `data.checks[]` with `metadata` populated, but its
306
+ `findings[]` is empty and nothing rolls up to top-level `warnings[]`. By contrast
307
+ `--skip services` removes the check entirely (no entry, no metadata).
308
+
309
+ ### 26. `CENTRS_CHECK_IGNORE` suppresses via env (precedence)
310
+
311
+ ```bash
312
+ CENTRS_CHECK_IGNORE=check/services/telnet-enabled \
313
+ centrs check $R --username $U --password $P --checks services --json
314
+ ```
315
+
316
+ Same effect as passing `--ignore check/services/telnet-enabled`; the winning
317
+ suppression source is reported in `meta.settings`. A `--ignore` arg for a
318
+ different code composes on top (both suppressed).
319
+
320
+ ### 27. `--fail-on never` never fails the run
321
+
322
+ Precondition: a custom script that emits an `error` finding (as in example 14).
323
+
324
+ ```bash
325
+ centrs check $R --username $U --password $P --yes --fail-on never --json \
326
+ --check-script '{:put [:serialize to=json {"errors"=[:toarray "policy breach"];"warnings"=[:toarray ""];"tips"=[:toarray ""];"metadata"={}}]}'
327
+ ```
328
+
329
+ `data.verdict = "fail"` but exit `0`; the error finding is still reported. This is
330
+ the pure-data-collection mode #149's IP-scan uses.
331
+
332
+ ### 28. Unknown check name → did-you-mean (offline)
333
+
334
+ <!-- cspell:ignore servces -->
335
+
336
+ ```bash
337
+ centrs check $R --checks servces --username $U --password $P
338
+ ```
339
+
340
+ A `usage/*` error names `servces` as unknown and lists the closest catalog ids
341
+ (`services`) — the shared did-you-mean behavior (constitution / commands help).
342
+ Exit non-zero, no network IO.
343
+
344
+ ## Fan-out and the write gate
345
+
346
+ ### 29. Fan-out reports partial success with exit 2
347
+
348
+ Fixture CDB group `lab`: one reachable CHR, one unreachable host.
349
+
350
+ ```bash
351
+ centrs check --group lab --json
352
+ ```
353
+
354
+ `FanoutData`: the reachable target's inner envelope is `ok: true`, the unreachable
355
+ one `ok: false` (`transport/*`); outer orchestration `ok: true`, process exit `2`.
356
+
357
+ ### 30. `--save` fan-out requires `--yes`
358
+
359
+ ```bash
360
+ centrs check --group lab --save --json
361
+ ```
362
+
363
+ Without `--yes` it errors before writing, naming the blast radius (how many CDB
364
+ records would change); adding `--yes` reconciles each target's record.
365
+
366
+ ## L2 and MNDP (CHR L2 bridge)
367
+
368
+ ### 31. A bare-MAC target runs the battery over mac-telnet
369
+
370
+ CHR-gated: needs the `socket-connect` L2 bridge (see `commands/discover/README.md`).
371
+ `$MAC` is the CHR's ether MAC.
372
+
373
+ ```bash
374
+ centrs check $MAC --username $U --password $P --json
375
+ ```
376
+
377
+ With no IP path, the battery rode mac-telnet (`meta.via = mac-telnet`) as one
378
+ consolidated script (Execution model); the health checks are `ran: true`.
379
+
380
+ ### 32. MNDP enrichment survives an auth failure
381
+
382
+ CHR-gated (L2 bridge). Precondition: wrong password.
383
+
384
+ ```bash
385
+ centrs check $R --username $U --password wrong --json
386
+ ```
387
+
388
+ `reach` still returns `ran: true` and `data.profile` carries MNDP-advertised
389
+ `identity`/`version`/`platform`/`board`; the health checks are `ran: false` (auth
390
+ failed). An auth error finding is surfaced, but the free MNDP facts are not lost.
@@ -89,22 +89,29 @@ when it actually selected between candidates.
89
89
  ## Comment kv-soup (per-device overrides)
90
90
 
91
91
  The CDB `comment` field is free text. centrs additionally parses tokens
92
- shaped like `key=value` out of it. Recognized keys (allowlist) fall in two
92
+ shaped like `key=value` out of it. Recognized keys (allowlist) fall in three
93
93
  groups: **lookup keys** affect how `<router>` resolves (see "Identity model");
94
- **override keys** are per-device defaults for centrs settings.
95
-
96
- | Key | Group | Effect |
97
- | ---------- | -------- | ---------------------------------------------------------------------- |
98
- | `identity` | lookup | Device `/system/identity`; a resolvable handle. May repeat across records. |
99
- | `mac` | lookup | A MAC, when `target` is not the MAC. |
100
- | `ip` | lookup | An IP, when `target` is not the IP. |
101
- | `via` | override | Default transport for this device (`rest-api`, `native-api`, `ssh`, …). |
102
- | `validate` | override | `true` / `false`. `false` is the escape hatch; CLI overrides. |
103
- | `timeout` | override | Default request timeout in ms. Per-transport caps still apply. |
104
- | `port` | override | Non-default transport port. Omitted when equal to the protocol default. |
105
- | `ssh-key` | override | Per-device private key file path for SSH transports. |
106
- | `source` | override | Provenance marker for discovered/imported records (`mndp`, `dude`, …). |
107
- | `mcp` | override | MCP write policy for this device: `ro` (default) or `rw`. See `commands/mcp/`. |
94
+ **override keys** are per-device defaults for centrs settings; **geo keys**
95
+ (issue #146) are facts, not settings — queryable via `--where` and returned in
96
+ the `location` block (see "Location / GPS" below), but never coerced into
97
+ resolver settings.
98
+
99
+ | Key | Group | Effect |
100
+ | --------------- | -------- | ---------------------------------------------------------------------- |
101
+ | `identity` | lookup | Device `/system/identity`; a resolvable handle. May repeat across records. |
102
+ | `mac` | lookup | A MAC, when `target` is not the MAC. |
103
+ | `ip` | lookup | An IP, when `target` is not the IP. |
104
+ | `via` | override | Default transport for this device (`rest-api`, `native-api`, `ssh`, …). |
105
+ | `validate` | override | `true` / `false`. `false` is the escape hatch; CLI overrides. |
106
+ | `timeout` | override | Default request timeout in ms. Per-transport caps still apply. |
107
+ | `port` | override | Non-default transport port. Omitted when equal to the protocol default. |
108
+ | `ssh-key` | override | Per-device private key file path for SSH transports. |
109
+ | `source` | override | Provenance marker for discovered/imported records (`mndp`, `dude`, …). |
110
+ | `mcp` | override | MCP write policy for this device: `ro` (default) or `rw`. See `commands/mcp/`. |
111
+ | `lat` | geo | Latitude, decimal degrees (`-90..90`). See "Location / GPS". |
112
+ | `lon` | geo | Longitude, decimal degrees (`-180..180`). Paired with `lat`. |
113
+ | `altitude` | geo | Meters, may be negative. Metadata-only (not a fan-out query predicate). |
114
+ | `altitude-type` | geo | `MSL`\|`AGL` (default `MSL`). Normalized to upper case for storage. |
108
115
 
109
116
  Rules:
110
117
 
@@ -128,9 +135,9 @@ Rules:
128
135
  is no current need to store a literally-empty value; revisit if one arises.
129
136
  - `commentMirror` is kept in sync with `comment` on every write.
130
137
 
131
- Precedence (lowest → highest): built-in defaults → project config →
132
- comment-kv → env (`CENTRS_*`) → CLI flag / API arg. `meta.settings` reports
133
- the winner and source per setting.
138
+ Precedence (lowest → highest): built-in defaults → user config file
139
+ (`centrs.env`) → comment-kv → env (`CENTRS_*`) → CLI flag / API arg.
140
+ `meta.settings` reports the winner and source per setting.
134
141
 
135
142
  ### Derived facts (queryable, may be stale)
136
143
 
@@ -147,7 +154,126 @@ a device); `license-id` is convenience metadata to match by hand against
147
154
  MikroTik's CHR licensing page. The `--where` selector plus this derived-fact
148
155
  allowlist are the planned home for "operate on a class of devices," distinct from
149
156
  `group`. (Promoting these keys to the comment-kv allowlist is a tracked code
150
- follow-up.)
157
+ follow-up; `lat`/`lon`/`altitude`/`altitude-type` — see "Location / GPS" below —
158
+ are the first keys promoted this way, issue #146.)
159
+
160
+ ## Location / GPS
161
+
162
+ Per-device GPS (issue #146) is a **fact, not a setting**: recognized
163
+ (`devices set lat=…` no longer warns `cdb/unknown-option`), stored, queryable via
164
+ `--where` (exact) and `--near`/`--bbox` (geo), and returned as a `location` block
165
+ in `devices show`/`list` output — but never coerced into resolver settings. This
166
+ section covers storage/flags/validation/envelope; the geo query predicates are in
167
+ "Geo query" below.
168
+
169
+ ### Storage keys
170
+
171
+ See the comment-kv table above for `lat`/`lon`/`altitude`/`altitude-type`.
172
+ Values are stored **verbatim as typed** (validated, not reformatted), so an
173
+ exact `--where lat=<value>` still matches what was written — `altitude-type` is
174
+ the one exception, since it's a closed two-value enum, so it is normalized to
175
+ upper case (`msl` -> `MSL`) before storage. `lat`/`lon` must be written as a
176
+ pair: setting one without the other, when the other is not already on the
177
+ record, errors `input/incomplete-gps`. Only the pairing rule fires per-write —
178
+ an unrelated `set timeout=…` on a record that already carries a lone `lat` is
179
+ left untouched.
180
+
181
+ `lat`/`lon`/`altitude` accept **strict decimal** only (optional leading `-`,
182
+ digits, optional dotted fraction); hex (`0x10`), scientific (`1e2`), and values
183
+ that overflow to `Infinity` are rejected with `input/invalid-coordinate` /
184
+ `input/invalid-altitude` even though `Number()` would accept them, because the
185
+ fields are decimal degrees / meters.
186
+
187
+ GPS validation (range, strict-decimal, pairing, `altitude-type` enum) applies to
188
+ the **setter flags** (`--lat`/`--lon`/`--altitude`/`--gps`) and to **bare `k=v`
189
+ update tokens** (`lat=…`, `lon=…`). It does **not** re-parse arbitrary base
190
+ `--comment` text: `devices add <t> --comment "lat=91"` is a freeform escape hatch
191
+ that stores the comment as-is (such a value simply won't resolve to a `location`
192
+ on read). Use the flags or `k=v` tokens when you want validated GPS.
193
+
194
+ ### Flags
195
+
196
+ `devices add`/`set` accept:
197
+
198
+ - `--lat <deg>` (alias `--latitude`)
199
+ - `--lon <deg>` (aliases `--lng`, `--longitude`, `--long`)
200
+ - `--altitude <meters>` (aliases `--alt`, `--ele`, `--elevation`) — `altitude`
201
+ stays the canonical field (the ISO 6709 / EPSG / IETF `geo:` / W3C-Geolocation
202
+ term); `ele`/`elevation` are GPX-muscle-memory aliases, not a rename
203
+ - `--altitude-type <MSL|AGL>` (alias `--alt-type`), case-insensitive
204
+ - `--gps <lat>,<lon>[,<altitude>[,<altitude-type>]]` — combined convenience;
205
+ minimum `--gps <lat>,<lon>`; a missing altitude-type in the 3-part form
206
+ defaults to `MSL`.
207
+
208
+ Bare `k=v` positionals also work (`lat=…`, `lon=…`, `altitude=…`,
209
+ `altitude-type=…`), through the same validation as the flags above. Aliases are
210
+ canonicalized to their table form in one place before storage/validation, so
211
+ the canonical key is always what lands in the comment and the envelope; typos
212
+ beyond the alias list are caught by the existing "Did you mean?" flag
213
+ suggester (`closestFlags`/`unknownFlagError`).
214
+
215
+ Validation errors reuse the `input/` arg-validation family:
216
+ `input/invalid-coordinate` (lat/lon NaN or out of range),
217
+ `input/invalid-altitude` (altitude NaN, or a bad altitude-type), and
218
+ `input/incomplete-gps` (lat/lon not paired, or `--gps` wrong arity).
219
+
220
+ ### Coordinate order is lat-first
221
+
222
+ `--gps 37.7749,-122.4194` is `lat,lon` — **latitude first, everywhere** —
223
+ matching ISO 6709 / EPSG:4326 authority order (and Google Maps / Leaflet /
224
+ most GPS devices), not GeoJSON's lon-first order. RFC 7946 (GeoJSON) describes
225
+ its own order as the opposite of "most GPS coordinates" and calls swapping them
226
+ "the most common GeoJSON bug"; lat-first is both what an agent guesses from the
227
+ `--gps` name and the standards-backed (ISO 6709) choice. A future GeoJSON
228
+ (lon-first) input path is deliberately out of scope here — it would ride a
229
+ structurally-typed shape (a real GeoJSON object / TikTOML import), never a bare
230
+ CSV.
231
+
232
+ ### Altitude is metadata-only
233
+
234
+ `altitude`/`altitude-type` are stored and returned in `devices show`/`list`
235
+ output, but are **not** part of any fan-out query predicate — the `--near`/
236
+ `--bbox` geo selectors are 2-D lat/lon only.
237
+
238
+ ### Geo query (`--near` / `--bbox`)
239
+
240
+ Two geo selectors filter/select by device GPS. On `devices list` they filter the
241
+ listing; on every non-terminal fan-out command (`execute`/`retrieve`/`api`/
242
+ `transfer`) they are union selector terms in the shared target-selection grammar
243
+ (`docs/CONSTITUTION.md`, Target selection). Coordinates are lat-first (above); a
244
+ geo-less device carries no location and is silently excluded (never an error).
245
+
246
+ - `--near <lat>,<lon>,<radius>` — devices within a great-circle (haversine)
247
+ radius of the point. `radius` takes a unit suffix `m`/`km`/`mi`/`ft`
248
+ (case-insensitive); a bare number is kilometers. A malformed/negative radius is
249
+ `input/invalid-radius`; wrong arity is `input/invalid-command`.
250
+ - `--bbox <south>,<west>,<north>,<east>` — devices inside the axis-aligned box
251
+ (`minLat,minLon,maxLat,maxLon`, edges inclusive). Requires `south <= north` and
252
+ `west <= east` (an antimeridian-crossing box is not supported in v1), else
253
+ `input/invalid-bbox`.
254
+
255
+ On `devices list`, `--group`/`--where`/`--near`/`--bbox` AND-narrow the listing
256
+ (a filter surface); `--near` and `--bbox` union with each other. In the fan-out
257
+ grammar every selector unions (see the constitution). `--near`/`--bbox` are
258
+ rejected on `add`/`set`/`show`/`remove` with `input/invalid-command` — they
259
+ select, they do not store.
260
+
261
+ ### Envelope
262
+
263
+ `devices show`/`list` add an optional `location` block to each entry that
264
+ carries a well-formed `lat`+`lon` pair, parsed from the comment facts:
265
+
266
+ ```json
267
+ { "lat": 37.7749, "lon": -122.4194, "altitude": 16, "altitudeType": "MSL" }
268
+ ```
269
+
270
+ A record with no (or malformed) `lat`/`lon` simply omits `location`.
271
+
272
+ ### Group-level GPS defers to #137
273
+
274
+ CDB groups are a bare string field on each entry — there is no
275
+ group-definition record to hang a group-level GPS or lat/lon inheritance on.
276
+ That needs TikTOML (#137); not doable in the CDB today.
151
277
 
152
278
  ## Groups
153
279
 
@@ -182,27 +308,33 @@ split lands.)
182
308
  ## Fanout (multi-target invocations)
183
309
 
184
310
  Any command may receive multiple positional targets, one or more `--group`
185
- flags, `--all`, `--default`, and/or `--where <attr>=<value>` (device-class) — see
186
- `docs/CONSTITUTION.md` (Target selection grammar). The resolved member set is
187
- de-duplicated by CDB record index.
188
-
189
- - Each member runs in parallel up to `--concurrency` (default
190
- `max(1, floor(os.cpus().length / 2))`).
191
- - `--fail-fast` aborts pending members on the first failure.
192
- - The outer envelope's `data` is an array of inner envelopes, one per member,
193
- in the resolved member order (CDB record index — see `devices` group
194
- expansion), not completion order, so repeated runs produce stable diffs.
195
- Members still execute in parallel and may *complete* out of order; progress
196
- is surfaced via `meta`, but the `data` array is always reassembled in the
197
- deterministic resolved order. `ok` of the outer envelope is `true` iff every
198
- inner envelope is `ok: true`.
199
- - Commands that are not safe to fan out (e.g. `terminal`) reject N > 1 with
200
- `usage/fanout-not-supported`.
311
+ flags, `--all`, `--default`, `--where <attr>=<value>` (device-class), and/or the
312
+ geo selectors `--near <lat>,<lon>,<radius>` / `--bbox <south>,<west>,<north>,<east>`
313
+ (see "Geo query" above) — see `docs/CONSTITUTION.md` (Target selection grammar).
314
+ The resolved member set is
315
+ de-duplicated by CDB record index (ad-hoc literal targets by host), and members
316
+ are reassembled in record-index order regardless of completion order, so repeated
317
+ runs produce stable diffs.
318
+
319
+ - Each member runs in parallel up to `--concurrency` (transport-aware defaults:
320
+ `rest-api` 8, `native-api` 4).
321
+ - Output is the locked `FanoutData` envelope with the granular `0`/`2`/`1` exit
322
+ code. The envelope shape (outer `ok` = orchestration success; a per-target
323
+ failure is an inner `ok: false`) and the exit-code contract are defined once in
324
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md) (Target selection) — not
325
+ restated here.
326
+ - A multi-target **write** is gated by `--yes`, confirmed once up front (not per
327
+ target); without it the error/tip names the blast radius (how many routers).
328
+ `--force` stays scoped to destructive `devices` CDB mutations.
329
+ - Commands that are not safe to fan out (`terminal`, and `api --stream` /
330
+ `--listen`) reject a multi-target selection with `usage/fanout-not-supported`.
201
331
 
202
332
  ## Subcommands
203
333
 
204
334
  ```text
205
- centrs devices list [--group G] [--format json|yaml|text]
335
+ centrs devices list [--group G] [--where attr=value]
336
+ [--near lat,lon,radius] [--bbox south,west,north,east]
337
+ [--format json|yaml|text]
206
338
  centrs devices show <target> [--explain] [--via <protocol>] [--match <type>]
207
339
  centrs devices groups [--members]
208
340
  centrs devices discover [--timeout 15s] [--group G] # = discover --save (save implied)
@@ -210,9 +342,13 @@ centrs devices add <target> [--user U] [--password P] [--new-group G]
210
342
  [--profile P|--profile-none|--profile-own]
211
343
  [--session S] [--comment "text k=v"]
212
344
  [--record-type ipAdmin|ipUser|macTarget|...]
345
+ [--gps lat,lon[,altitude[,altitude-type]]]
346
+ [--lat D] [--lon D] [--altitude M] [--altitude-type MSL|AGL]
213
347
  centrs devices set <selector…> [--user U] [--password P] [--new-group G]
214
348
  [--profile P|--profile-none|--profile-own]
215
349
  [--session S] [k=v ...]
350
+ [--gps lat,lon[,altitude[,altitude-type]]]
351
+ [--lat D] [--lon D] [--altitude M] [--altitude-type MSL|AGL]
216
352
  centrs devices remove <selector…>
217
353
  centrs devices edit <target> # future: clack/TUI wizard
218
354
  ```
@@ -226,7 +362,11 @@ field-editing verb, and invoking it today returns `usage/not-implemented`. There
226
362
  is no `update`.
227
363
 
228
364
  - `list` (alias `print`) shows resolved targets, their record type, group, and
229
- a one-line provenance summary. No network IO.
365
+ a one-line provenance summary. No network IO. `--where attr=value`
366
+ (repeatable, AND-combined) filters by CDB-stored facts/comment-kv plus core
367
+ record fields (`target`/`group`/`identity`/`mac`) — e.g. `--where
368
+ lat=37.7749`. `--near lat,lon,radius` and `--bbox south,west,north,east`
369
+ filter by device GPS (see "Geo query"). All three are `list`-only.
230
370
  - `show` (alias `get`) returns a single resolved target with the full per-field
231
371
  source map in `meta.target`. `--explain` adds the raw CDB record dump under
232
372
  `data.record`; `--via <protocol>` reports the protocol that would be selected