@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -486,3 +486,151 @@ Each alias produces the same envelope as its canonical verb — `meta.operation`
486
486
  reports the canonical command (`list`/`show`/`remove`), never the alias. An
487
487
  unknown verb still errors with `input/invalid-command`, and the error lists the
488
488
  canonical verbs plus the accepted aliases.
489
+
490
+ ## Location / GPS
491
+
492
+ Slice 1 of issue #146: `lat`/`lon`/`altitude`/`altitude-type` are recognized
493
+ comment-kv facts (see `README.md`, Location / GPS) — stored, queryable via
494
+ `--where`, and returned as a `location` block in `devices show`/`list` output.
495
+ Coordinate order is **lat-first everywhere**: `--gps <lat>,<lon>[,…]`.
496
+
497
+ ### 42. `add --gps` sets lat/lon/altitude; `show` returns a `location` block
498
+
499
+ ```bash
500
+ centrs devices add 198.51.100.40 --user admin --password p \
501
+ --gps 37.7749,-122.4194,16 --cdb-file $CDB
502
+ ```
503
+
504
+ `ok: true`. Re-reading via `show` returns
505
+ `data.entry.location = { lat: 37.7749, lon: -122.4194, altitude: 16, altitudeType: "MSL" }`
506
+ (the 3-part `--gps` form defaults the missing altitude-type to `MSL`).
507
+
508
+ ### 43. Discrete `--lat`/`--lon` round-trip the same way as `--gps`
509
+
510
+ ```bash
511
+ centrs devices set 192.0.2.5 --lat 51.5072 --lon -0.1276 --cdb-file $CDB
512
+ ```
513
+
514
+ `ok: true`. `devices show 192.0.2.5` returns
515
+ `data.entry.location = { lat: 51.5072, lon: -0.1276 }` (no `altitude` key —
516
+ altitude was never set on this record).
517
+
518
+ ### 44. `--altitude` alone defaults `altitudeType` to `MSL` in the envelope
519
+
520
+ ```bash
521
+ centrs devices set 192.0.2.6 --lat 48.8566 --lon 2.3522 --altitude 35 \
522
+ --cdb-file $CDB
523
+ ```
524
+
525
+ `ok: true`. `altitude-type` was never written to the comment (only
526
+ `--altitude` was passed), but `devices show 192.0.2.6` still returns
527
+ `data.entry.location.altitudeType = "MSL"` — the metadata-only default applied
528
+ at read time (`deviceLocation` in `src/resolver/geo.ts`).
529
+
530
+ ### 45. Setting `lat` without `lon` errors with `input/incomplete-gps`
531
+
532
+ ```bash
533
+ centrs devices set 198.51.100.1 --lat 10 --cdb-file $CDB
534
+ ```
535
+
536
+ Errors with `input/incomplete-gps` (target `198.51.100.1` carries no prior
537
+ `lon`). No write; no backup written.
538
+
539
+ ### 46. An out-of-range `--lat` errors with `input/invalid-coordinate`
540
+
541
+ ```bash
542
+ centrs devices set 192.0.2.5 --lat 200 --lon 0 --cdb-file $CDB
543
+ ```
544
+
545
+ Errors with `input/invalid-coordinate`. No write; no backup written.
546
+
547
+ ### 47. An invalid `--altitude-type` errors with `input/invalid-altitude`
548
+
549
+ ```bash
550
+ centrs devices set 192.0.2.5 --lat 10 --lon 10 --altitude-type wgs84 \
551
+ --cdb-file $CDB
552
+ ```
553
+
554
+ Errors with `input/invalid-altitude`. No write; no backup written.
555
+
556
+ ### 48. Alias flags canonicalize to the stored key; no more `cdb/unknown-option`
557
+
558
+ ```bash
559
+ centrs devices set 192.0.2.5 --latitude 10 --lng 20 --cdb-file $CDB
560
+ ```
561
+
562
+ `ok: true`, **no** `cdb/unknown-option` warning — `--latitude`/`--lng` are
563
+ canonicalized to `lat`/`lon` in one place (`canonicalizeGeoKey`,
564
+ `src/resolver/geo.ts`) before storage. `devices show 192.0.2.5` returns
565
+ `data.entry.comment` containing `lat=10` and `lon=20` (the canonical keys), and
566
+ `data.entry.location = { lat: 10, lon: 20 }`.
567
+
568
+ ### 49. `list --where lat=<verbatim>` matches the exact stored string
569
+
570
+ ```bash
571
+ centrs devices list --where lat=37.774900 --cdb-file $CDB
572
+ ```
573
+
574
+ `ok: true`; matches only the record whose stored `lat` fact is the exact
575
+ string `37.774900` (values are stored verbatim as typed, never reformatted —
576
+ see `README.md`, Location / GPS). `--where lat=37.7749` (numerically equal,
577
+ reformatted) matches nothing.
578
+
579
+ ## Geo query (`list --near` / `list --bbox`)
580
+
581
+ `--near`/`--bbox` filter `devices list` by device GPS (great-circle radius or a
582
+ lat-first bounding box). Coordinates are lat-first everywhere (see `README.md`,
583
+ Location / GPS); a geo-less device carries no location and is silently excluded.
584
+ The same predicates ride the shared fan-out grammar on every non-terminal
585
+ command (`execute`/`retrieve`/`api`/`transfer`) — see `docs/CONSTITUTION.md`
586
+ (Target selection).
587
+
588
+ ### 50. `list --near <lat>,<lon>,<radius>` selects in-radius devices
589
+
590
+ `192.0.2.5` is in San Francisco and `192.0.2.6` in New York (set via `--gps`);
591
+ the other records carry no GPS.
592
+
593
+ ```bash
594
+ centrs devices list --near 37.7749,-122.4194,50km --cdb-file $CDB
595
+ ```
596
+
597
+ `ok: true`; `data` holds only `192.0.2.5`. New York is ~4100 km away (outside the
598
+ 50 km radius) and the geo-less records never match. The radius unit is one of
599
+ `m`/`km`/`mi`/`ft`; a bare number is kilometers.
600
+
601
+ ### 51. `list --bbox <south>,<west>,<north>,<east>` selects devices in the box
602
+
603
+ ```bash
604
+ centrs devices list --bbox 37.70,-122.52,37.83,-122.35 --cdb-file $CDB
605
+ ```
606
+
607
+ `ok: true`; `data` holds only the device whose GPS falls inside the lat-first
608
+ box (edges inclusive). The box must satisfy `south <= north` and `west <= east`
609
+ (no antimeridian wrap), else `input/invalid-bbox`.
610
+
611
+ ### 52. `--near`/`--bbox` are `list`-only
612
+
613
+ ```bash
614
+ centrs devices set 192.0.2.5 --near 37.7749,-122.4194,50km --cdb-file $CDB
615
+ ```
616
+
617
+ Errors with `input/invalid-command`: `--where`/`--near`/`--bbox` *select*
618
+ records for `list`; `add`/`set` name the `<target>` directly. Store GPS with
619
+ `--gps`/`--lat`/`--lon` instead.
620
+
621
+ ### 53. An unrelated `set` on a record with a lone `lat` does not fail
622
+
623
+ The lat/lon pairing check fires only when the write actually touches a
624
+ coordinate. A record that already carries a lone `lat` (e.g. a hand-edited or
625
+ imported comment) can still take unrelated updates.
626
+
627
+ ```bash
628
+ centrs devices add 203.0.113.7 --user u --password p \
629
+ --comment "lat=37.7749" --cdb-file $CDB
630
+ centrs devices set 203.0.113.7 timeout=5000 --cdb-file $CDB
631
+ ```
632
+
633
+ Both succeed: the `add` stores the freeform comment verbatim, and the later
634
+ `set timeout=5000` does **not** raise `input/incomplete-gps` even though the
635
+ record's GPS pair is incomplete. Touching `lat`/`lon` (e.g. `set lat=…`) would
636
+ re-enable the pairing check.
@@ -6,8 +6,9 @@ CLI-shaped commands — there is no separate `update` command.
6
6
 
7
7
  Status: `rest-api`, `native-api`, `mac-telnet`, and `ssh` are `CHR-passed` (see
8
8
  `docs/MATRIX.md` and `commands/execute/examples.md`, examples 1–11 over REST,
9
- 12–18 over the native API, 19–21 over mac-telnet, and S1–S4 over ssh, green via
10
- `bun run test:integration`). `romon` and `winbox-terminal` remain
9
+ 12–19 over the native API, 20–22 over mac-telnet, and S1–S4 over ssh, green via
10
+ `bun run test:integration`), including multi-target fan-out (see **Target
11
+ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
11
12
  `not-started`. SNMP is retrieve-only and rejects `execute`.
12
13
 
13
14
  ## Intent
@@ -52,6 +53,12 @@ Status: `rest-api`, `native-api`, `mac-telnet`, and `ssh` are `CHR-passed` (see
52
53
  else passes its `detail`/body plus the HTTP status through
53
54
  `mapRouterOsError({ transport: "rest-api", httpStatus })`. There is no longer
54
55
  a hand-rolled `routeros/*` table on the REST side.
56
+ - **Run-time fault mapping — later (JG-28).** `mapRouterOsError`'s table covers
57
+ the faults seen so far; a known gap is errors that *pass* `:parse`/validation
58
+ but fail at **run time** (a syntactically valid command the router rejects when
59
+ executed). `tikoci/vscode-tikbook` has an error-string mapping for these worth
60
+ porting into `src/core/routeros-errors.ts`. Background research, not yet
61
+ scheduled — captured here so it isn't lost (no GitHub issue by design).
55
62
  - Output is *string*-shaped for script-mode; richer parsing is a future
56
63
  concern. The envelope must still distinguish RouterOS errors from successful
57
64
  runs (a 200 with a RouterOS error string is still an error — see
@@ -111,8 +118,14 @@ Status: `rest-api`, `native-api`, `mac-telnet`, and `ssh` are `CHR-passed` (see
111
118
  | `--yes` | Confirm write-shaped add/set/remove commands in non-interactive runs. |
112
119
  | `--max-bytes <n>` | Byte budget for the rendered envelope; excess output is truncated with a warning + `meta.truncated` (not an error), matching `retrieve`. (Renamed from `--max-results`.) |
113
120
  | `--format <text\|json\|yaml>` | Output format (alias `--json`). Defaults to `text`; `CENTRS_FORMAT` sets the default. |
121
+ | `--group <name>` | Run across every CDB record in WinBox group `<name>`. Repeatable, combinable with `<target>` positionals plus `--where`/`--all`/`--default`; de-duped by record index. See **Target selection**. |
114
122
  | `--where <attr>=<value>` | Device-class selector — run across every CDB record whose stored fact/comment-kv matches (e.g. `--where board=RB5009 /system/package/check-for-updates`). Repeatable (AND). See constitution: target selection. |
115
- | `--` | End-of-options marker. Every token after `--` is taken as the literal RouterOS command, even flag-shaped ones — e.g. `centrs execute $R -- /interface print where disabled=yes`. Use it when the command contains tokens that would otherwise be claimed as centrs flags; otherwise the command must be quoted as a single argument. |
123
+ | `--all` | Run across every CDB record (excludes the reserved `__default__`). See **Target selection**. |
124
+ | `--default` | Select the reserved `__default__` record. See **Target selection**. |
125
+ | `--near <lat>,<lon>,<radius>` | Geo selector — run across CDB records whose GPS is within the great-circle radius (units `m`/`km`/`mi`/`ft`; bare = km). Lat-first. Union predicate; geo-less devices never match. See **Target selection**. |
126
+ | `--bbox <south>,<west>,<north>,<east>` | Geo selector — run across CDB records whose GPS is inside the lat-first bounding box. Union predicate. See **Target selection**. |
127
+ | `--concurrency <n>` | Max in-flight targets during fan-out (integer ≥ 1). Transport-aware default: `rest-api` 8, others 4. |
128
+ | `--` | End-of-options marker. Every token after `--` is taken as the literal RouterOS command, even flag-shaped ones — e.g. `centrs execute $R -- /interface print where disabled=yes`. In fan-out, positional targets come BEFORE `--` and the command AFTER it (`centrs execute r1 r2 -- /system/reboot`). Use it when the command contains tokens that would otherwise be claimed as centrs flags; otherwise the command must be quoted as a single argument. |
116
129
 
117
130
  ## SSH key selection
118
131
 
@@ -141,6 +154,34 @@ contents, and any inline key material are always sensitive and must be listed in
141
154
  redacted only if the caller marks them sensitive or the error couples them with
142
155
  key material.
143
156
 
157
+ ## Target selection
158
+
159
+ `execute` runs the **same** RouterOS command across a multi-target selection over
160
+ the shared grammar (multiple `<target>` positionals, repeatable `--group`/
161
+ `--where`, the geo selectors `--near`/`--bbox`, `--all`, `--default`). The
162
+ grammar, the locked `FanoutData` envelope,
163
+ the record-order reassembly, and the granular **0/2/1 exit code** are normative in
164
+ [`docs/CONSTITUTION.md` → Target selection grammar](../../docs/CONSTITUTION.md#target-selection-grammar).
165
+
166
+ execute's positional **boundary is `--`** (it differs from `api`/`retrieve`,
167
+ whose last positional is the operation arg, because an execute command is itself
168
+ many tokens):
169
+
170
+ - `execute r1 r2 -- /system/reboot` — targets `[r1, r2]` before `--`, command
171
+ after. **More than one positional target requires `--`.**
172
+ - `execute --group prod /system/resource/print` — selector-only: targets come
173
+ from the selector and every positional is the command (no `--` needed).
174
+ - `execute r1 /system/resource/print` — a single positional target with no
175
+ selector stays **single-target** (legacy split: first positional is the target,
176
+ the rest is the command), never `FanoutData`.
177
+
178
+ Because `execute` is the primary **write** path, a write-shaped fan-out
179
+ (add/set/remove) is gated by **`--yes`**, confirmed **once** up front; without it
180
+ the error names the blast radius (how many routers). Write-ness is determined from
181
+ the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
182
+ fires before any target is dialed. Per-target validation still runs because
183
+ RouterOS schemas can differ by version.
184
+
144
185
  ## mac-telnet L2 validation
145
186
 
146
187
  Decided (2026-06-06): the real-L2 integration path is `@tikoci/quickchr`'s
@@ -156,7 +197,7 @@ macOS needs `SO_REUSEPORT`; mcast works on Linux/CI). Grounding: quickchr
156
197
 
157
198
  `execute / mac-telnet` is now **`CHR-passed`** end to end over real L2 against
158
199
  stock CHR 7.23.1 (`test/integration/mac-telnet-console.test.ts` +
159
- `mactelnet-l2-bridge.ts`, examples 19–21): `executeEnvelope` resolves a MAC
200
+ `mactelnet-l2-bridge.ts`, examples 20–22): `executeEnvelope` resolves a MAC
160
201
  target, the `MacTelnetAdapter` opens a `MacTelnetConsole` over a UDP datagram
161
202
  transport, and reads, writes (REST-verified), and validation-rejects all work.
162
203
 
@@ -195,9 +236,9 @@ Protocol selection: an unresolved MAC target auto-selects mac-telnet (covered by
195
236
  ## Open shape questions
196
237
 
197
238
  - ~~How to expose multi-line / async / progress output~~ — **resolved:**
198
- open-ended follow/streaming reads are the separate read-only `stream` verb
199
- (NDJSON stream of envelopes; see `commands/stream/README.md`). `execute` stays
200
- single-shot read/write; it does not grow a follow mode.
239
+ open-ended follow/streaming reads are `api … --stream` (NDJSON stream of
240
+ envelopes; see `commands/api/README.md`). `execute` stays single-shot
241
+ read/write; it does not grow a follow mode.
201
242
  - ~~Whether `--strict` should reject "succeeded with stderr-like content."~~ —
202
243
  **decided:** `--strict` promotes any warning on an otherwise-successful run to
203
244
  `ok: false` (nonzero exit), like `-Werror`; the default stays lenient
@@ -2,8 +2,8 @@
2
2
 
3
3
  Each numbered example is an executable spec, run against a CHR 7.23 router booted
4
4
  by `@tikoci/quickchr`: `test/integration/execute.test.ts` covers examples 1–11
5
- over REST and 12–18 over the native API; `test/integration/mac-telnet-console.test.ts`
6
- covers examples 19–21 over mac-telnet (real L2 via the `socket-connect` bridge).
5
+ over REST and 12–19 over the native API; `test/integration/mac-telnet-console.test.ts`
6
+ covers examples 20–22 over mac-telnet (real L2 via the `socket-connect` bridge).
7
7
  If a line here is not exercised by a test, the test file is wrong; if a line
8
8
  passes only with `validate=false`, the **implementation** is wrong (see
9
9
  `docs/CONSTITUTION.md`).
@@ -242,6 +242,23 @@ centrs execute $A '/ip/address/add address=198.51.100.23/32 interface=ether1 com
242
242
  Envelope: `ok: false`, `error.code=usage/confirmation-required`,
243
243
  `meta.via=native-api`, no validation command is sent, and no address is added.
244
244
 
245
+ ### 19. Script-mode command runs through `/execute` with `as-string` (parity with REST)
246
+
247
+ A non-path-shaped console expression over native-api falls through to
248
+ `/execute` the same way REST does (example 4), instead of being refused.
249
+ `as-string` makes native `/execute` block and return the captured output
250
+ rather than scheduling a background job and returning only its id.
251
+
252
+ ```bash
253
+ centrs execute $A ':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P
254
+ ```
255
+
256
+ Envelope: `ok: true`, `data.ret` is a string containing the CHR identity (same
257
+ shape and cross-transport value as example 4 over REST), `meta.via=native-api`,
258
+ and `meta.validation.syntax=true`. Regression coverage for the bug where the
259
+ native adapter sent `/execute` without `as-string`, silently discarding
260
+ captured output.
261
+
245
262
  ## mac-telnet (`--via mac-telnet`)
246
263
 
247
264
  Layer-2 execute over the RouterOS interactive console (UDP/20561). `$MAC` is the
@@ -252,11 +269,11 @@ is a **single console `:put [:parse …]`** — it reports both `syntax error` a
252
269
  at once (no `/console/inspect` table parsing). The console opens through a
253
270
  first-login license screen (auto-answered) and a ~10s terminal-negotiation stall;
254
271
  **a successful write prints nothing** over the console (no `.id`/`ret`, unlike
255
- REST/native). Examples 19–21 are green via `bun run test:integration`
272
+ REST/native). Examples 20–22 are green via `bun run test:integration`
256
273
  (`test/integration/mac-telnet-console.test.ts`, CHR 7.23.1) over the real L2
257
274
  bridge; the console reader is also exercised directly in the same test.
258
275
 
259
- ### 19. Read a command over mac-telnet
276
+ ### 20. Read a command over mac-telnet
260
277
 
261
278
  ```bash
262
279
  centrs execute $MAC '/system/identity/print' --via mac-telnet --host $MT_HOST --port $MT_PORT --username $U --password $P
@@ -266,7 +283,7 @@ Envelope: `ok: true`, `meta.via=mac-telnet`, `data.ret` is the console output an
266
283
  contains the device identity (cross-checked against REST). `meta.validation.source`
267
284
  is `:put [:parse ...] over mac-telnet`.
268
285
 
269
- ### 20. Write (add) over mac-telnet
286
+ ### 21. Write (add) over mac-telnet
270
287
 
271
288
  ```bash
272
289
  centrs execute $MAC '/ip/address/add address=198.51.100.40/32 interface=ether1' --via mac-telnet --host $MT_HOST --port $MT_PORT --username $U --password $P --yes
@@ -276,7 +293,7 @@ Envelope: `ok: true`, `meta.via=mac-telnet`, `data.ret` is empty (a successful
276
293
  console write prints nothing — there is no `.id` to return). A subsequent REST
277
294
  read of `/ip/address` shows `198.51.100.40`.
278
295
 
279
- ### 21. Validation rejects an unknown attribute over mac-telnet
296
+ ### 22. Validation rejects an unknown attribute over mac-telnet
280
297
 
281
298
  ```bash
282
299
  centrs execute $MAC '/ip/address/add address=198.51.100.41/32 interface=ether1 no-such-arg=x' --via mac-telnet --host $MT_HOST --port $MT_PORT --username $U --password $P --yes
@@ -337,6 +354,73 @@ centrs execute 127.0.0.1 '/ip/address/add address=198.51.100.51/32 interface=eth
337
354
  Envelope: `ok: false`, `error.code=validation/unknown-attribute`, `meta.via=ssh`,
338
355
  and no address is added — the console `:parse` gate catches `bogus` pre-mutation.
339
356
 
357
+ ## Target selection (fan-out)
358
+
359
+ These exercise the shared target-selection grammar
360
+ ([`docs/CONSTITUTION.md` → Target selection grammar](../../docs/CONSTITUTION.md#target-selection-grammar)),
361
+ run by `test/integration/execute-fanout.test.ts` when CHR integration is enabled.
362
+ `$CDB` has two records in group `fanout-chr`: record 0 is the live CHR (comment
363
+ fact `role=edge`) and record 1 is an unreachable REST URL (`role=core`). Note the
364
+ execute `--` boundary: targets/selectors come before it, the command after.
365
+
366
+ ### F1. Group fan-out of a read command
367
+
368
+ ```bash
369
+ centrs execute --group fanout-chr --via rest-api --cdb-file $CDB --json -- /system/resource/print
370
+ ```
371
+
372
+ `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`, targets ordered by
373
+ `recordIndex`, `meta.operation.kind = fanout`; the unreachable target is an inner
374
+ `ok: false` (`transport/connection-refused`). Process exit `2`.
375
+
376
+ ### F2. Empty / unknown group
377
+
378
+ ```bash
379
+ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --json -- /system/resource/print
380
+ ```
381
+
382
+ `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
383
+ `warnings` include `cdb/empty-group`. Exit `0`.
384
+
385
+ ### F3. `--where` device-class selector (subset)
386
+
387
+ ```bash
388
+ centrs execute --where role=edge --via rest-api --cdb-file $CDB --json -- /system/resource/print
389
+ ```
390
+
391
+ Selects only record 0: `data.summary = { total: 1, ok: 1, failed: 0 }`,
392
+ `meta.operation.selection.where = ["role=edge"]`, exit `0`.
393
+
394
+ ### F4. `--all` (every CDB record)
395
+
396
+ ```bash
397
+ centrs execute --all --via rest-api --cdb-file $CDB --json -- /system/resource/print
398
+ ```
399
+
400
+ `data.summary = { total: 2, ok: 1, failed: 1 }`,
401
+ `meta.operation.selection.all = true`, exit `2`.
402
+
403
+ ### F5. Write-shaped fan-out without `--yes` is refused (blast radius)
404
+
405
+ ```bash
406
+ centrs execute --group fanout-chr --via rest-api --cdb-file $CDB --json -- /system/identity/set name=fanout-blast
407
+ ```
408
+
409
+ A write-shaped fan-out is gated once up front: `ok: false`,
410
+ `error.code = usage/confirmation-required`, the summary names the blast radius
411
+ (`2 router(s)`), exit `1`. Nothing is mutated. Add `--yes` to fan the write out.
412
+
413
+ ### F6. Multiple positional targets (ad-hoc literals) before `--`
414
+
415
+ ```bash
416
+ centrs execute $REACHABLE_URL $UNREACHABLE_URL --via rest-api --username $U --password $P --json -- /system/resource/print
417
+ ```
418
+
419
+ More than one positional target is fan-out mode (the `--` separates them from the
420
+ command). With no `--cdb-file`, both are ad-hoc literals, labeled by
421
+ `meta.target.input` with no `recordIndex`: `data.summary = { total: 2, ok: 1,
422
+ failed: 1 }`, exit `2`.
423
+
340
424
  ## Protocol selection notes
341
425
 
342
426
  For an unresolved MAC target, execute auto-selection defaults to mac-telnet
@@ -1,5 +1,9 @@
1
1
  # retrieve
2
2
 
3
+ Status: `CHR-passed` over `rest-api` and `native-api`, including multi-target
4
+ fan-out (see **Target selection**). `snmp` is `not-started`. Matches
5
+ `docs/MATRIX.md`.
6
+
3
7
  Read RouterOS state. RouterOS menu reads model `<path>/<verb>` where the verb
4
8
  is `print`-style (`print`, `get`, and async POST-shaped reads as they're
5
9
  added). SNMP reads use `retrieve <router> snmp <oid|MIB name>` and resolve
@@ -45,7 +49,7 @@ centrs retrieve <router> snmp <oid|MIB name> [flags]
45
49
  | `--attributes <a,b,c>` | Alias for `--attribute` with comma-separated input. |
46
50
  | `--all-attributes` | Equivalent to RouterOS `details=true`. Mutually exclusive with `--attribute(s)`. |
47
51
  | `--list-attributes` (alias `--list`) | Return the available attribute names for the path. No `print`/`get` is run. |
48
- | `--once` | Bounded single read of a monitor-style menu (RouterOS `once`): returns **one** envelope and never follows. Open-ended follow is the separate `stream` verb. See constitution: protocol selection. |
52
+ | `--once` | Bounded single read of a monitor-style menu (RouterOS `once`): returns **one** envelope and never follows. Open-ended follow is `api … --stream`. See constitution: protocol selection. |
49
53
  | `--query <expr>` | RouterOS-side **row** filter (maps to `.query`), repeatable; returns matching rows as an **array**. This is how you read one row by name (`--query name=ether1`). **Not Implemented** yet — returns `validation/not-implemented`. |
50
54
  | `--filter <expr>` | RouterOS row filter; same `.query` mapping and not-implemented handling as `--query`. |
51
55
  | `--where <attr>=<value>` | Device-class selector: fan out across CDB records whose stored fact/comment-kv matches (e.g. `--where board=RB5009`). Repeatable (AND). Filters *which devices*, not RouterOS rows. See constitution: target selection. |
@@ -64,7 +68,11 @@ centrs retrieve <router> snmp <oid|MIB name> [flags]
64
68
  | `--cdb-file` / `--cdb-password` | Override CDB file location / decrypt password. |
65
69
  | `--ros-version <version>` | SNMP MIB lookup only: pin the MikroTik MIB version to cache/download. |
66
70
  | `--ros-channel <channel>` | SNMP MIB lookup only: `stable`, `long-term`, `testing`, or `development`; default `stable`. |
67
- | `--group <name>` | Fan out across every CDB target in WinBox group `<name>`. Repeatable, and combinable with `<router>` positionals plus `--all`/`--default`; the union is de-duped by CDB record index. See **Group fanout**. |
71
+ | `--group <name>` | Fan out across every CDB target in WinBox group `<name>`. Repeatable, and combinable with `<router>` positionals plus `--all`/`--default`; the union is de-duped by CDB record index. See **Target selection**. |
72
+ | `--all` | Fan out across every CDB record (excludes the reserved `__default__`). See **Target selection**. |
73
+ | `--default` | Select the reserved `__default__` record. See **Target selection**. |
74
+ | `--near <lat>,<lon>,<radius>` | Geo selector: fan out across CDB records whose GPS is within the great-circle radius (units `m`/`km`/`mi`/`ft`; bare = km). Lat-first. Union predicate; geo-less devices never match. See **Target selection**. |
75
+ | `--bbox <south>,<west>,<north>,<east>` | Geo selector: fan out across CDB records whose GPS is inside the lat-first bounding box. Union predicate. See **Target selection**. |
68
76
  | `--concurrency <n>` | Max in-flight targets during fanout (integer ≥ 1). Defaults are transport-aware: `rest-api` 8, `native-api` 4. Rejected with `usage/invalid-concurrency` otherwise. |
69
77
 
70
78
  ## Validation
@@ -137,55 +145,33 @@ warning entry — it is not an error. (Earlier text said a byte overflow returns
137
145
  *error* with the needed size; superseded — truncation is an `ok: true` footnote so
138
146
  partial data stays usable.)
139
147
 
140
- ## Group fanout
141
-
142
- `--group <name>` loads the CDB once, expands every matching WinBox record in
143
- that group, de-dupes by CDB record index, and runs the same per-target retrieve
144
- pipeline for each target. Per the shared target-selector grammar, `--group` is
145
- repeatable and combines freely with one or more `<router>` positionals, `--all`
146
- (every CDB record), and `--default` (the `__default__` record); the union is
147
- de-duped by CDB record index. (Earlier drafts made `--group` mutually exclusive
148
- with a positional — superseded by the liberal selector model.)
149
-
150
- Fanout output is one outer envelope whose `ok` reports orchestration success:
151
- `ok: false` is reserved for failures before reliable per-target results exist
152
- (for example invalid flags or CDB decrypt failure). Per-target success/failure
153
- is data, not metadata:
154
-
155
- ```ts
156
- {
157
- ok: true,
158
- data: {
159
- summary: { total: 2, ok: 1, failed: 1 },
160
- targets: [
161
- { ok: true, data: { ... }, meta: { target: { recordIndex: 0 }, ... } },
162
- { ok: false, error: { code: "transport/connection-refused", ... }, meta: { target: { recordIndex: 1 }, ... } }
163
- ]
164
- },
165
- warnings: [],
166
- meta: {
167
- target: {},
168
- via: "rest-api", // null if inner targets disagree on protocol
169
- settings: { ... },
170
- operation: {
171
- kind: "fanout",
172
- group: "prod-edge",
173
- concurrency: 8,
174
- summary: { total: 2, ok: 1, failed: 1 },
175
- request: { path: "/system/resource", validate: true, format: "json", ... }
176
- }
177
- }
178
- }
179
- ```
180
-
181
- An empty or unknown group is `ok: true` with
182
- `data.summary = { total: 0, ok: 0, failed: 0 }`, `data.targets = []`, and a
183
- `cdb/empty-group` warning. Validation runs per target because RouterOS schemas
184
- can differ by version. Fanout retries each target up to two times only for
185
- `transport/network` and `transport/connection-closed`; REST 5xx failures are
186
- mapped to `transport/connection-closed`. It does not retry `routeros/*`,
187
- `validation/*`, `auth/*`, `cdb/*`, `target/*`, timeouts, DNS, TLS, or refused
188
- connections.
148
+ ## Target selection
149
+
150
+ retrieve fans out over the **shared target-selection grammar** — multiple
151
+ `<router>` positionals, repeatable `--group`/`--where`, the geo selectors
152
+ `--near`/`--bbox`, `--all`, and `--default`, combined as a set and de-duped by
153
+ CDB record index. The grammar, the locked `FanoutData`
154
+ envelope (`data = { summary, targets[] }`, outer `ok` = orchestration success,
155
+ per-target failure is an inner `ok:false`), the record-order reassembly, and the
156
+ granular **0/2/1 exit code** are all normative in
157
+ [`docs/CONSTITUTION.md` → Target selection grammar](../../docs/CONSTITUTION.md#target-selection-grammar).
158
+ The boundary is intent-keyed: a plain single-positional `retrieve <router> <path>`
159
+ stays the single-target envelope; a selector flag or a second positional target
160
+ switches to fan-out.
161
+
162
+ retrieve-specific behavior on top of the shared engine:
163
+
164
+ - The CDB is loaded + decrypted **once** for the whole fan-out; each target is
165
+ then resolved and **validated independently**, because RouterOS schemas can
166
+ differ by version (`/console/inspect` runs per target).
167
+ - An empty or unknown selection is `ok: true` with
168
+ `data.summary = { total: 0, ok: 0, failed: 0 }`, `data.targets = []`, a
169
+ `cdb/empty-group` / `cdb/empty-selection` warning, and exit `0`.
170
+ - Each target retries up to two times **only** for `transport/network` and
171
+ `transport/connection-closed` (REST 5xx is mapped to
172
+ `transport/connection-closed`). It does **not** retry `routeros/*`,
173
+ `validation/*`, `auth/*`, `cdb/*`, `target/*`, timeouts, DNS, TLS, or refused
174
+ connections.
189
175
 
190
176
  ## Definition of done
191
177
 
@@ -162,15 +162,16 @@ Call succeeds with a `cdb/password-not-needed` warning in `meta.warnings`.
162
162
  centrs retrieve $R /system/resource --cdb-file $CDB --cdb-password ignored --json
163
163
  ```
164
164
 
165
- ## Group fanout
165
+ ## Target selection (fan-out)
166
166
 
167
- These are exercised by `test/integration/fanout-retrieve.test.ts` when CHR
168
- integration is enabled.
167
+ These exercise the shared target-selection grammar
168
+ ([`docs/CONSTITUTION.md` → Target selection grammar](../../docs/CONSTITUTION.md#target-selection-grammar)).
169
+ `test/integration/fanout-retrieve.test.ts` runs them when CHR integration is
170
+ enabled. `$CDB` contains two records in group `fanout-chr`: record 0 is the live
171
+ CHR (comment fact `role=edge`) and record 1 is an unreachable REST URL (comment
172
+ fact `role=core`).
169
173
 
170
- ### F1. Group fanout with one inner success and one inner failure
171
-
172
- `$CDB` contains two records in group `fanout-chr`: record 0 is the live CHR and
173
- record 1 is an unreachable REST URL.
174
+ ### F1. Group fan-out with one inner success and one inner failure
174
175
 
175
176
  ```bash
176
177
  centrs retrieve --group fanout-chr /system/resource --cdb-file $CDB --json
@@ -179,7 +180,7 @@ centrs retrieve --group fanout-chr /system/resource --cdb-file $CDB --json
179
180
  Outer envelope: `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`,
180
181
  `data.targets` is ordered by CDB `recordIndex`, and `meta.operation.kind` is
181
182
  `fanout`. The unreachable target is an inner `ok: false` envelope with
182
- `transport/connection-refused`.
183
+ `transport/connection-refused`. Process exit `2` (partial).
183
184
 
184
185
  ### F2. Empty / unknown group
185
186
 
@@ -188,7 +189,37 @@ centrs retrieve --group no-such-group /system/resource --cdb-file $CDB --json
188
189
  ```
189
190
 
190
191
  Envelope: `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
191
- `data.targets = []`, and warnings include `cdb/empty-group`.
192
+ `data.targets = []`, and warnings include `cdb/empty-group`. Exit `0`.
193
+
194
+ ### F3. `--where` device-class selector (subset)
195
+
196
+ ```bash
197
+ centrs retrieve --where role=edge /system/resource --cdb-file $CDB --json
198
+ ```
199
+
200
+ `--where` matches the raw comment fact, selecting only record 0:
201
+ `data.summary = { total: 1, ok: 1, failed: 0 }`,
202
+ `meta.operation.selection.where = ["role=edge"]`, exit `0`.
203
+
204
+ ### F4. `--all` (every CDB record)
205
+
206
+ ```bash
207
+ centrs retrieve --all /system/resource --cdb-file $CDB --json
208
+ ```
209
+
210
+ `--all` fans across every record (excluding `__default__`) — here both, so
211
+ `data.summary = { total: 2, ok: 1, failed: 1 }`,
212
+ `meta.operation.selection.all = true`, exit `2`.
213
+
214
+ ### F5. Multiple positional targets (ad-hoc literals)
215
+
216
+ ```bash
217
+ centrs retrieve $REACHABLE_URL $UNREACHABLE_URL /system/resource --username $U --password $P --json
218
+ ```
219
+
220
+ More than one positional target is fan-out mode without any selector flag. With
221
+ no `--cdb-file`, both are ad-hoc literals, labeled by `meta.target.input` with no
222
+ `recordIndex`: `data.summary = { total: 2, ok: 1, failed: 1 }`, exit `2`.
192
223
 
193
224
  ## Format
194
225