@tikoci/centrs 0.1.2 → 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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
@@ -0,0 +1,408 @@
1
+ # settings — examples
2
+
3
+ Each numbered example is an executable spec, in the same style as
4
+ `commands/devices/examples.md`. `settings` performs no network IO, so its
5
+ integration tests run against a fixture `centrs.env` rather than a live CHR.
6
+
7
+ `$SETTINGS_DIR` is a per-test temporary directory passed as `XDG_CONFIG_HOME`
8
+ for the invocation, so the resolved file is `$SETTINGS_DIR/tikoci/centrs.env`
9
+ — there is no checked-in fixture file; each test starts from either an absent
10
+ file or one written in-test. This mirrors `$CDB` in the `devices` fixtures.
11
+ Some examples also reference `$CDB` (a fixture WinBox CDB, built the same way
12
+ as `devices`' fixtures) to exercise the `__default__` boundary between the two
13
+ files.
14
+
15
+ Every example below is a named test in `test/integration/settings.test.ts`
16
+ ("example N: ..."), green via `bun run test:integration:settings`.
17
+
18
+ ## print
19
+
20
+ ### 1. Print with no file present
21
+
22
+ ```bash
23
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print
24
+ ```
25
+
26
+ No `centrs.env` exists yet. `data` lists every settings-managed key with
27
+ `source: { kind: "default", key: <attr> }`. For `format`, since the built-in
28
+ default varies per command, `data.format = { value: null, source: { kind:
29
+ "default", key: "format" }, perCommandDefault: { retrieve: "text", execute:
30
+ "text", transfer: "text", api: "json" } }` instead of a single scalar.
31
+
32
+ ### 2. Print after a value is set
33
+
34
+ ```bash
35
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format json
36
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print
37
+ ```
38
+
39
+ `data.format = { value: "json", source: { kind: "config", key:
40
+ "CENTRS_FORMAT" } }`. No `perCommandDefault` block once an override exists —
41
+ the file's value applies uniformly.
42
+
43
+ ### 3. Print --all surfaces unrecognized lines
44
+
45
+ The fixture file has a hand-added `# my note` comment and a
46
+ `CENTRS_QUUX=5s` line (not a real key) before any `set` call in this test.
47
+
48
+ ```bash
49
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print --all
50
+ ```
51
+
52
+ `data` includes an `unrecognized` array: `[{ key: "CENTRS_QUUX", value:
53
+ "5s" }]`, tagged `(unrecognized)`. Bare `print` (no `--all`) from the same
54
+ fixture omits it entirely — it isn't a key `settings` knows, managed or
55
+ refused.
56
+
57
+ ### 4. Print a single attribute
58
+
59
+ ```bash
60
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print max-results
61
+ ```
62
+
63
+ `data` contains only the `max-results` entry, not the full set.
64
+
65
+ ### 5. Real env overrides the file
66
+
67
+ ```bash
68
+ XDG_CONFIG_HOME=$SETTINGS_DIR CENTRS_FORMAT=yaml centrs settings print format
69
+ ```
70
+
71
+ The file (from example 2) holds `CENTRS_FORMAT=json`, but the real process
72
+ environment wins per precedence: `data.format = { value: "yaml", source: {
73
+ kind: "env", key: "CENTRS_FORMAT" } }`.
74
+
75
+ ### 6. Print under --skip-env-file still shows the file
76
+
77
+ ```bash
78
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print --skip-env-file
79
+ ```
80
+
81
+ `ok: true`. Values and sources are unchanged from example 2 — `settings`
82
+ itself always reads the real file. `meta.warnings` includes a note that
83
+ `--skip-env-file` is active and other commands run in this same environment
84
+ would not see these values.
85
+
86
+ ## get
87
+
88
+ ### 7. Get a key with no override and a per-command default
89
+
90
+ ```bash
91
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings get format
92
+ ```
93
+
94
+ Fresh file. `data.value = null`, `data.source = { kind: "default", key:
95
+ "format" }`, `data.perCommandDefault` populated as in example 1 above. This
96
+ is `get`'s one deliberate exception to "returns one value" — there truly
97
+ isn't one until an override exists.
98
+
99
+ ### 8. Get after set returns the resolved value
100
+
101
+ ```bash
102
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set max-results 250
103
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings get max-results
104
+ ```
105
+
106
+ `data = { value: 250, source: { kind: "config", key: "CENTRS_MAX_RESULTS" } }`.
107
+
108
+ ### 9. Get an unknown key
109
+
110
+ ```bash
111
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings get totally-bogus-key
112
+ ```
113
+
114
+ Errors `settings/unknown-key` — the token isn't a recognized `CENTRS_*`-shaped
115
+ name at all (not a typo close enough to suggest, not a plausible env name).
116
+ `details_url` populated.
117
+
118
+ ### 10. Get a credential-shaped key redacts the value
119
+
120
+ ```bash
121
+ XDG_CONFIG_HOME=$SETTINGS_DIR CENTRS_CDB_PASSWORD=hunter2 centrs settings get cdb-password
122
+ ```
123
+
124
+ `ok: true` — read-only inspection is allowed even for refused-on-write keys
125
+ (see README Scope boundary). `data = { value: "(redacted)", isSet: true,
126
+ source: { kind: "env", key: "CENTRS_CDB_PASSWORD" } }`. The literal secret
127
+ `hunter2` must not appear anywhere in the envelope, including
128
+ `meta`/`error.context` on any code path — this example exists specifically to
129
+ pin that.
130
+
131
+ ### 11. Get a key that also has a per-device comment-kv form
132
+
133
+ ```bash
134
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set via ssh
135
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings get via
136
+ ```
137
+
138
+ `ok: true`. `data = { value: "ssh", source: { kind: "config", key:
139
+ "CENTRS_VIA" } }` — fully validated and managed like any other key (see
140
+ README: there is no second-class "unmanaged" tier). `tips` (the envelope's
141
+ existing informational channel, not `warnings`) carries a
142
+ `tip/comment-kv-may-override` entry noting that a device's own `via=`
143
+ comment-kv override, if set, takes precedence over this global default —
144
+ since `settings` has no target/device context here, it cannot say whether
145
+ any specific device actually has one.
146
+
147
+ ## set
148
+
149
+ ### 12. Set a known key (happy path)
150
+
151
+ ```bash
152
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format json
153
+ ```
154
+
155
+ `ok: true`. `data = { key: "format", previous: null, value: "json" }`. Reading
156
+ the file directly shows a single `CENTRS_FORMAT=json` line.
157
+
158
+ ### 13. Prefix and case are normalized on input, canonical on write
159
+
160
+ ```bash
161
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set CENTRS_MAX_RESULTS 250
162
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set max-results 250
163
+ ```
164
+
165
+ Both calls resolve to the same key and produce the identical
166
+ `CENTRS_MAX_RESULTS=250` line — the second call is a no-op mutation (file
167
+ unchanged, `data.previous == data.value == 250`, still `ok: true`).
168
+
169
+ ### 14. Set an invalid value for a typed key
170
+
171
+ ```bash
172
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format xml
173
+ ```
174
+
175
+ Errors `settings/invalid-format` (the shared `{text, json, yaml}` union — see
176
+ README). No file mutation; a failed `set` never partially writes.
177
+
178
+ ### 15. First write creates the directory and file
179
+
180
+ ```bash
181
+ XDG_CONFIG_HOME=$SETTINGS_DIR/does-not-exist-yet centrs settings set format text
182
+ ```
183
+
184
+ `ok: true`. `$SETTINGS_DIR/does-not-exist-yet/tikoci/` is created, along with
185
+ `centrs.env` inside it.
186
+
187
+ ### 16. Set preserves unrelated file content
188
+
189
+ Fixture file (written in-test, not via `settings`) contains:
190
+
191
+ ```env
192
+ # personal note, keep me
193
+ CENTRS_VIA=ssh
194
+ CENTRS_FORMAT=text
195
+ ```
196
+
197
+ ```bash
198
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format json
199
+ ```
200
+
201
+ Re-reading the file shows the comment and the `CENTRS_VIA=ssh` line
202
+ byte-for-byte unchanged; only the `CENTRS_FORMAT` line's value changed to
203
+ `json`. (`via` is a fully managed key too — this example is about `set` only
204
+ touching the one line it was asked to touch, not about `via` being
205
+ second-class.)
206
+
207
+ ### 17. Set a refused credential key
208
+
209
+ ```bash
210
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set cdb-password hunter2
211
+ ```
212
+
213
+ Errors `settings/reserved-key`. `remediation` points at `--cdb-password` /
214
+ `CENTRS_CDB_PASSWORD` / `Bun.secret()`. No file mutation — re-reading the
215
+ file (or a fresh `print`) shows no `CENTRS_CDB_PASSWORD` line was written.
216
+ The literal value `hunter2` does not appear anywhere in the error envelope.
217
+
218
+ ### 18. Set a comment-kv-shadowed key is a normal validated write
219
+
220
+ ```bash
221
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set validate false
222
+ ```
223
+
224
+ `ok: true`. `data = { key: "validate", previous: null, value: false }`. No
225
+ warning — `validate` is validated with the same `parseBoolean` rule as any
226
+ other boolean key; the fact that a device's own `validate=` comment-kv can
227
+ override it per-device is normal precedence layering, not a reason to treat
228
+ this write differently from `set format json`.
229
+
230
+ ### 19. Set an unrecognized key name
231
+
232
+ ```bash
233
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set totally-bogus-key value
234
+ ```
235
+
236
+ Errors `settings/unknown-key`, same as `get` (example 9). No file mutation.
237
+
238
+ ### 20. Set a typed integer key out of range
239
+
240
+ ```bash
241
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set max-results 0
242
+ ```
243
+
244
+ Errors `settings/invalid-integer` (positive-integer rule, matching
245
+ `resolveOptionalIntegerSetting`'s existing validation used elsewhere). `-1`
246
+ and non-numeric input (`abc`) behave the same.
247
+
248
+ ### 21. Set an invalid protocol identifier
249
+
250
+ ```bash
251
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set via carrier-pigeon
252
+ ```
253
+
254
+ Errors `settings/invalid-via` — same enum and code `retrieve.ts` already uses
255
+ for `--via`/`CENTRS_VIA` (`rest-api`, `native-api`, `ssh`, `snmp`, `mndp`,
256
+ `mac-telnet`, `romon`, `winbox-terminal`). No file mutation.
257
+
258
+ ### 22. Set an invalid duration
259
+
260
+ ```bash
261
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set timeout not-a-duration
262
+ ```
263
+
264
+ Errors `settings/invalid-timeout` (`parseDuration` rules: bare milliseconds
265
+ or a `ms`/`s`/`m` suffix). No file mutation.
266
+
267
+ ### 23. Boolean values are written in canonical 1/0 form
268
+
269
+ ```bash
270
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set mcp-allow-adhoc true
271
+ ```
272
+
273
+ `ok: true`. Reading the file directly shows `CENTRS_MCP_ALLOW_ADHOC=1`, not
274
+ `=true` — `settings` accepted the `parseBoolean`-recognized spelling `true`
275
+ as input but always writes the canonical `1`/`0` (see README: this is
276
+ required because `src/mcp/config.ts` checks the literal string `"1"`, not the
277
+ shared boolean parser). `settings get mcp-allow-adhoc` afterward returns
278
+ `{ value: true, source: { kind: "config", ... } }` — `get` still returns a
279
+ real boolean, only the on-disk serialization is canonicalized.
280
+
281
+ ### 24. A value that will fail later still gets a warning now
282
+
283
+ ```bash
284
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set transfer-via ftp
285
+ ```
286
+
287
+ `ok: true` — `ftp` is a recognized `CENTRS_TRANSFER_VIA` value syntactically,
288
+ so `set` writes it. `meta.warnings` contains a note that `transfer` will
289
+ currently reject `ftp` at call time unless `ALLOW_UNSAFE_PROTOCOLS` includes
290
+ it (`settings/unsafe-protocol-blocked`, `src/transfer.ts`) — `settings` warns
291
+ rather than silently accepting a value it knows will not work, without
292
+ duplicating `transfer`'s own runtime gate.
293
+
294
+ ### 25. Re-setting the same value is idempotent
295
+
296
+ ```bash
297
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format json
298
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format json
299
+ ```
300
+
301
+ Both calls return `ok: true`; the second reports `data.previous ==
302
+ data.value`. The file has exactly one `CENTRS_FORMAT=` line afterward, not
303
+ two.
304
+
305
+ ## reset
306
+
307
+ ### 26. Reset removes the line, not just its value
308
+
309
+ ```bash
310
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings set format json
311
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings reset format
312
+ ```
313
+
314
+ `ok: true`. The `CENTRS_FORMAT=` line is **absent** from the file afterward —
315
+ not present with an empty value (see README: an empty value would be
316
+ "set to empty string" for a bash-sourced file, not "unset"). A follow-up
317
+ `settings get format` shows `source: { kind: "default" }` again, matching
318
+ example 7.
319
+
320
+ ### 27. Reset on a key that was never set is a no-op success
321
+
322
+ ```bash
323
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings reset max-results
324
+ ```
325
+
326
+ Fresh file, `CENTRS_MAX_RESULTS` never written. `ok: true`,
327
+ `data.wasSet: false` — resetting nothing is not an error.
328
+
329
+ ### 28. Reset with no attribute clears every managed key, leaves the rest
330
+
331
+ Fixture file:
332
+
333
+ ```env
334
+ # keep me
335
+ SOME_OTHER_VAR=untouched
336
+ CENTRS_FORMAT=json
337
+ CENTRS_MAX_RESULTS=250
338
+ CENTRS_VIA=ssh
339
+ ```
340
+
341
+ ```bash
342
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings reset
343
+ ```
344
+
345
+ `ok: true`. `data.cleared = ["format", "max-results", "via"]` — all three
346
+ recognized keys, `via` included, since it's fully managed like the others.
347
+ Re-reading the file shows the comment and `SOME_OTHER_VAR=untouched`
348
+ unchanged; the three managed lines are gone. No confirmation was required
349
+ (see README: local-file blast radius, always recoverable via `set`).
350
+
351
+ ### 29. Reset also refuses credential keys
352
+
353
+ ```bash
354
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings reset cdb-password
355
+ ```
356
+
357
+ Errors `settings/reserved-key`, same family as `set` (example 17) — `reset`
358
+ is a mutating verb and refuses the same key list.
359
+
360
+ ## Interactive / non-TTY
361
+
362
+ ### 30. Bare `settings` under non-TTY behaves like `print`
363
+
364
+ ```bash
365
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings </dev/null
366
+ ```
367
+
368
+ `ok: true`, identical shape to `settings print` (example 1/2 depending on
369
+ fixture state). No prompt is emitted and the call does not hang.
370
+
371
+ The interactive TTY flow (clack-style prompts collecting `__default__`
372
+ credentials and confirming preferences) is explicitly deferred — see the
373
+ README's "later slice" note and its `@clack/prompts` dependency prerequisite.
374
+ It has no example here because a genuine TTY session isn't exercisable in the
375
+ fixture-backed integration tier (the same reason `devices` has no interactive
376
+ example either, only the `</dev/null` disambiguation case). When that slice
377
+ ships, its examples land as a new numbered range appended here, not inserted
378
+ into the middle of this list.
379
+
380
+ ## `__default__` boundary
381
+
382
+ ### 31. Print reports `__default__` presence without leaking secrets
383
+
384
+ Fixture CDB `$CDB` has a `__default__` record with `user=admin` and a
385
+ password set.
386
+
387
+ ```bash
388
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print --cdb-file $CDB
389
+ ```
390
+
391
+ `ok: true`. `data.defaultDevice = { configured: true, user: "admin",
392
+ passwordSet: true }` — the password value itself never appears. This is the
393
+ one place `settings print` reaches into the CDB rather than `centrs.env`;
394
+ `--cdb-file` here is a probe-only flag (see README Surface), not a settings
395
+ precedence source. It's read-only (no CDB mutation), matching the README's
396
+ "the `__default__` record only" boundary.
397
+
398
+ ### 32. Print with no `__default__` record
399
+
400
+ ```bash
401
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print --cdb-file /tmp/does-not-exist.cdb
402
+ ```
403
+
404
+ `ok: true` (a missing/absent CDB is not an error for `settings`, unlike
405
+ `devices list` in devices' example 3 — `settings` degrades to "no default
406
+ device configured" rather than `cdb/not-found`, since reporting settings
407
+ should not require a CDB to exist at all). `data.defaultDevice = {
408
+ configured: false }`.
@@ -2,6 +2,12 @@
2
2
 
3
3
  Open an interactive RouterOS console.
4
4
 
5
+ `terminal` is **single-session**: it is not a fan-out surface. A target-selection
6
+ flag (`--group` / `--where` / `--all` / `--default` / `--concurrency`) is rejected
7
+ with `usage/fanout-not-supported` — open a console to one router at a time; use
8
+ `execute` / `api` for multi-target commands. See `docs/CONSTITUTION.md`
9
+ (Target selection grammar).
10
+
5
11
  Status: `terminal / mac-telnet` **and** `terminal / ssh` are both **`CHR-passed`**
6
12
  (see `docs/MATRIX.md` and `examples.md` T1–T3 / TS1–TS2). The two transports take
7
13
  different paths: **mac-telnet** is the in-process raw passthrough over
@@ -17,6 +17,7 @@ sftp-seeded >60 KB file) — confirming the `/file`
17
17
  RouterOS. Unit coverage is `test/unit/transfer.test.ts`. **sftp** is the first SSH
18
18
  consumer — a self-contained SFTP transfer client over the host OpenSSH `sftp`
19
19
  subsystem. `scp` and `fetch` are **designed but deferred** past this pass.
20
+ Multi-target fan-out is built (see **Target selection**, examples F1–F5).
20
21
 
21
22
  ## Intent
22
23
 
@@ -160,7 +161,8 @@ is an explicit `--via scp` escape hatch. The reason is capability, not taste:
160
161
  | `--verify <size\|checksum\|off>` | Post-transfer integrity check. Default `size` (compare the byte count centrs sent/received against the settled `/file` size). `off` (alias `--no-verify`) skips it. See *Integrity*. |
161
162
  | `--type <file\|directory\|disk\|package>` | `list` only: filter by `/file` row type. |
162
163
  | `--name <glob>` | `list` only: filter by file name glob. |
163
- | `--out-dir <dir>` | `download` fanout only *(deferred — fanout not built yet)*: write one file per target into `<dir>` (named by CDB target), since N devices cannot share one local path. |
164
+ | `--out-dir <dir>` | `download` fan-out only: write one file per target into `<dir>` (named by CDB identity, collision-safe, keeping the remote extension), since N devices cannot share one local path. **Required** for a `download` across a selection. See **Target selection**. |
165
+ | `--yes` | Confirm a mutating fan-out (`upload`/`remove`/`mkdir`/`copy`) across multiple routers in non-interactive runs (confirmed once up front). See **Target selection**. |
164
166
  | `--advertise-host <host>` / `--advertise-port <n>` / `--bind <addr>` | `fetch` only *(deferred)*: the host/IP/port centrs advertises in the fetch URL and the local bind address. Default: auto-detect the local IP on the route to the router; ephemeral port; single-use random URL token. |
165
167
  | `--resolve <none\|arp>` | *(deferred — not parsed yet)* Resolve a MAC-address target to an IP via the host ARP cache. Default `none`. |
166
168
  | `--format text` (default) | Human summary: method, bytes, duration, verified. Errors print `[code] summary` + `Fix:` lines. |
@@ -172,7 +174,35 @@ is an explicit `--via scp` escape hatch. The reason is capability, not taste:
172
174
  | `--ssh-key <path>` | `sftp` (and future `scp`): explicit private-key path. Same `sshKey` setting as `terminal`/`execute` (`CENTRS_SSH_KEY`, CDB `ssh-key=`). When unset, the ssh-agent / `~/.ssh/config` is used. See `commands/terminal/README.md`. |
173
175
  | `--insecure` | Accept a self-signed TLS cert (`https`/`api-ssl`) or a new SSH host key (TOFU). Default verifies; see constitution: transport trust. Adds a `transport/insecure-trust` warning. |
174
176
  | `--cdb-file` / `--cdb-password` | Override CDB file location / decrypt password. |
175
- | `--group <name>` / `--where <attr>=<value>` / `--all` / `--default` / `--concurrency <n>` | *(deferred — fanout not built for `transfer` yet)* Fan out across CDB targets (e.g. push one firmware file to a fleet). Same selector grammar and per-target envelope as `retrieve` (constitution: target selection). `download` fanout requires `--out-dir`; `remove`/`mkdir`/`copy`/`upload` fan out directly. |
177
+ | `--group <name>` / `--where <attr>=<value>` / `--near <lat>,<lon>,<radius>` / `--bbox <south>,<west>,<north>,<east>` / `--all` / `--default` / `--concurrency <n>` | Fan out across CDB targets (e.g. push one firmware file to a fleet). Same selector grammar and per-target envelope as `retrieve`/`execute` (constitution: target selection). `--near`/`--bbox` select by device GPS (lat-first). `download` fan-out requires `--out-dir`; `remove`/`mkdir`/`copy`/`upload` fan out directly and are `--yes`-gated. See **Target selection**. |
178
+
179
+ ## Target selection
180
+
181
+ `transfer` runs the **same** verb across a multi-target selection over the shared
182
+ grammar (multiple `<router>` positionals, repeatable `--group`/`--where`, the geo
183
+ selectors `--near`/`--bbox`, `--all`, `--default`). The grammar, the locked
184
+ `FanoutData` envelope, the record-order
185
+ reassembly, and the granular **0/2/1 exit code** are normative in
186
+ [`docs/CONSTITUTION.md` → Target selection grammar](../../docs/CONSTITUTION.md#target-selection-grammar).
187
+
188
+ The positional **boundary is the verb keyword** (`upload`/`download`/`list`/
189
+ `remove`/`mkdir`/`copy` or an alias): positionals **before** it are fan-out
190
+ targets, the verb and its paths follow (`transfer r1 r2 download /flash/log.txt
191
+ --out-dir ./logs`). A single positional target with no selector stays
192
+ single-target. On the top-level `upload`/`download` alias the verb is fixed, so
193
+ its positionals are paths — positional fan-out targets are not expressible there;
194
+ fan-out comes from a selector flag.
195
+
196
+ Two transfer-specific rules:
197
+
198
+ - **`download` fan-out requires `--out-dir <dir>`.** N devices cannot share one
199
+ local path, so each target's file is written into the directory, named by its
200
+ CDB identity (collision-safe — same-label targets are disambiguated by record
201
+ index — and keeping the remote file's extension). Each inner envelope records
202
+ its per-target local path.
203
+ - **`upload`/`remove`/`mkdir`/`copy` are device writes**, so a fan-out of them is
204
+ gated by **`--yes`**, confirmed once up front; without it the error names the
205
+ blast radius (how many routers). `download`/`list` only read the device.
176
206
 
177
207
  ## Integrity
178
208
 
@@ -336,6 +336,72 @@ centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $U --ssh-key $K
336
336
  Both `ok: true`. On-device `copy` has no SFTP primitive — `--via sftp copy` returns
337
337
  `transport/unsupported-operation` (it stays on rest/native).
338
338
 
339
+ ## Target selection (fan-out)
340
+
341
+ These exercise the shared target-selection grammar
342
+ ([`docs/CONSTITUTION.md` → Target selection grammar](../../docs/CONSTITUTION.md#target-selection-grammar)),
343
+ run by `test/integration/transfer-fanout.test.ts`. `$CDB` has two records in group
344
+ `fanout-chr`: record 0 is the live CHR (comment fact `role=edge`, seeded with
345
+ `fanout-test.txt`) and record 1 is an unreachable REST URL (`role=core`). Note the
346
+ **verb-keyword boundary**: targets/selectors precede the verb.
347
+
348
+ ### F1. Group fan-out of a read verb (`list`)
349
+
350
+ ```bash
351
+ centrs transfer --group fanout-chr list --via rest --cdb-file $CDB --json
352
+ ```
353
+
354
+ `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`, targets ordered by
355
+ `recordIndex`, `meta.operation.kind = fanout`; the unreachable target is an inner
356
+ `ok: false`. Exit `2`.
357
+
358
+ ### F2. Empty / unknown group
359
+
360
+ ```bash
361
+ centrs transfer --group no-such-group list --via rest --cdb-file $CDB --json
362
+ ```
363
+
364
+ `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
365
+ `warnings` include `cdb/empty-group`. Exit `0`.
366
+
367
+ ### F3. `--where` device-class selector (subset)
368
+
369
+ ```bash
370
+ centrs transfer --where role=edge list --via rest --cdb-file $CDB --json
371
+ ```
372
+
373
+ Selects only record 0: `data.summary = { total: 1, ok: 1, failed: 0 }`,
374
+ `meta.operation.selection.where = ["role=edge"]`, exit `0`.
375
+
376
+ ### F4. `download` fan-out into `--out-dir`
377
+
378
+ ```bash
379
+ centrs transfer --group fanout-chr download fanout-test.txt --out-dir ./downloads --via rest --cdb-file $CDB --json
380
+ ```
381
+
382
+ Each reachable target writes one file into `./downloads`, named by its CDB identity
383
+ (collision-safe, keeping the remote extension). `data.summary = { total: 2, ok: 1,
384
+ failed: 1 }`, exit `2`; the ok target's inner `data.local` is the written path.
385
+
386
+ ### F5. Mutating fan-out (`remove`) without `--yes` is refused
387
+
388
+ ```bash
389
+ centrs transfer --group fanout-chr remove fanout-test.txt --via rest --cdb-file $CDB --json
390
+ ```
391
+
392
+ A mutating fan-out is gated once up front: `ok: false`,
393
+ `error.code = usage/confirmation-required`, the summary names the blast radius
394
+ (`2 router(s)`), exit `1`. Nothing is removed. Add `--yes` to fan the write out.
395
+
396
+ ### F6. `download` fan-out without `--out-dir` is refused
397
+
398
+ ```bash
399
+ centrs transfer --group fanout-chr download fanout-test.txt --via rest --cdb-file $CDB --json
400
+ ```
401
+
402
+ N devices cannot share one local path: `ok: false`,
403
+ `error.code = usage/conflicting-flags`, exit `1`. Pass `--out-dir <dir>`.
404
+
339
405
  ## Pending transports (contract defined; flips when the transport lands)
340
406
 
341
407
  ### P2. `--via scp` (follow-on pass after sftp)
@@ -221,19 +221,40 @@ must round-trip through the same envelope.
221
221
 
222
222
  One grammar across every non-terminal command. A call selects targets from any
223
223
  mix of: `<router>` positionals (one or more), repeatable `--group <name>`,
224
- `--all` (every CDB record), `--default` (the `__default__` record), and
225
- repeatable `--where <attr>=<value>` — a **device-class** selector that matches
226
- CDB-stored facts/comment-kv (e.g. `--where board=RB5009`), AND-combined across
227
- repeats. The union is **de-duped by CDB record index** and run through the same
228
- per-target pipeline; the `data` array is reassembled in resolved record order,
229
- not completion order, so repeated runs diff cleanly. Destructive multi-target
230
- operations (e.g. `devices remove` across a group) require `--force`. `terminal`
231
- and a single-session `api --stream` follow are not fan-out surfaces and reject
232
- N>1 with `usage/fanout-not-supported`.
224
+ `--all` (every CDB record, **excluding** the reserved `__default__` record),
225
+ `--default` (the `__default__` record), and repeatable `--where <attr>=<value>` —
226
+ a **device-class** selector that matches CDB-stored facts/comment-kv **plus core
227
+ record fields** (`target`/`identity`/`group`/`mac`; core fields win so a comment
228
+ token cannot spoof them), AND-combined across repeats. A call is in **fan-out
229
+ mode** when any selector flag is present **or** more than one positional target is
230
+ given; a plain single-positional call stays single-target. The union is
231
+ **de-duped by CDB record index** (ad-hoc literal targets by host) and run through
232
+ the same per-target pipeline.
233
+
234
+ Fan-out output is the locked **`FanoutData`** envelope: `data = { summary, targets[]
235
+ }`, where the outer `ok` reports **orchestration** success and a per-target failure
236
+ is an inner `ok:false` envelope (output, not metadata). `targets[]` is reassembled
237
+ in resolved record order, not completion order, so repeated runs diff cleanly. The
238
+ **process exit code** is granular and uniform across fan-out commands: `0` every
239
+ target ok (or an empty selection), `2` partial (some ok, some failed), `1`
240
+ orchestration error or every target failed.
241
+
242
+ A multi-target **RouterOS write** (e.g. `execute` / `api` add/set/remove across a
243
+ selection) is gated by **`--yes`**, confirmed **once** up front; when it is missing
244
+ the error names the blast radius (how many routers). The separate **`--force`**
245
+ gate is scoped to destructive **`devices` CDB mutations** (e.g. `devices remove`
246
+ across a group), not RouterOS writes. `terminal` and single-session `stream` /
247
+ `api --stream` are not fan-out surfaces and reject N>1 with
248
+ `usage/fanout-not-supported`; `api --raw` is rejected in fan-out mode with
249
+ `usage/conflicting-flags` (it strips the per-target envelope).
233
250
 
234
251
  `--where` filters *which devices* by CDB-stored facts; keep it distinct from
235
252
  `retrieve`/`execute`'s `--query`/`--filter`, which filter *RouterOS rows* in the
236
- response. Two layers, two flag families.
253
+ response. Two layers, two flag families. `--near <lat>,<lon>,<radius>` and
254
+ `--bbox <south>,<west>,<north>,<east>` are geo union predicates over device GPS
255
+ (**lat-first, decimal degrees**; radius units `m`/`km`/`mi`/`ft`, bare = km);
256
+ geo-less devices never match. They join the same union — a device matches when it
257
+ falls in `--near`'s radius **or** `--bbox`.
237
258
 
238
259
  `--group` is purely a **selector** in this grammar; the group *field* is written
239
260
  by a distinct `--new-group` flag on `devices add`/`set`, so record selection and
@@ -281,7 +302,11 @@ Rules:
281
302
  - **Never silently downgrade across `--via`.** If the caller pinned a transport
282
303
  that cannot do the operation, error out with a `transport/*` code.
283
304
  Auto-selection (no `--via`) may downgrade *within* the table, but every hop is
284
- reported in `meta.warnings` with its reason.
305
+ reported in `meta.warnings` with its reason. For `api`'s fan-out path, this
306
+ and "Validation is the product" above are also checked structurally by
307
+ CodeQL (`.github/codeql-house-rules/missing-required-gate.ql` —
308
+ `apiFanout` must call `assertApiProtocolSupported`/`validateApiRequestShape`;
309
+ scoped to `apiFanout` only today).
285
310
  - **Execute vs retrieve surfaces:** native-api, rest-api, ssh, mac-telnet (and
286
311
  later RoMON / WinBox Terminal) are execute surfaces; SNMP is **retrieve-only**.
287
312
  mac-telnet is the default execute path for an unresolved-MAC target — IP-level