@tikoci/centrs 0.1.7 → 0.1.8

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 +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. package/src/transfer.ts +234 -32
@@ -15,7 +15,10 @@ Conventions (provided by the harness):
15
15
  - `$A` is `<host>` and `$API_PORT` is the native-API port (`chr.ports.api`) for
16
16
  `--via native-api`.
17
17
  - `$SRC` is a small local text file (~2 KB) the harness creates; `$TMP` is a
18
- scratch directory for downloads. `$BIG` is a local file **larger than 60 KB**.
18
+ scratch directory for downloads. `$BIG` is a local file **larger than 60 KB**:
19
+ every byte value, then deterministic noise, with a UTF-8 `€` straddling each
20
+ 32 KiB `/file/read` chunk boundary. `$BIN` is the same shape at exactly 60 KB,
21
+ the largest the `/file/set` write cap allows.
19
22
  - Device-side paths are **bare** (a CHR has no `flash` disk, so everything
20
23
  persists for the session); the harness removes `centrs-*` test files between
21
24
  runs. The CHR free license caps throughput at 1 Mb/s, so fixtures stay small —
@@ -77,6 +80,15 @@ centrs transfer $R list --name 'centrs-*' --username $U --password $P --json
77
80
 
78
81
  Envelope: every row's `name` matches the glob; `centrs-up.txt` is present.
79
82
 
83
+ ### H1. `--host` stands in for `<router>` (#428)
84
+
85
+ ```bash
86
+ centrs transfer --host $R list --username $U --password $P --json
87
+ ```
88
+
89
+ `ok: true`, with `data` the device's file list. Nothing comes before the verb,
90
+ so `--host` is the target and there is no CDB lookup.
91
+
80
92
  ## Validate before write
81
93
 
82
94
  ### 6. Refuse overwrite without `--force`
@@ -202,8 +214,8 @@ centrs transfer $R download centrs-big.bin $TMP/big.bin --username $U --password
202
214
  ```
203
215
 
204
216
  Envelope: `ok: true`, `meta.via="rest-api"`, `data.bytes` equals the seeded size
205
- (>60 KB). `$TMP/big.bin` is byte-identical to the seed — proving the chunked read
206
- reassembles correctly across `/file/read` calls.
217
+ (>60 KB). `$TMP/big.bin` has the seed's sha256 — proving the chunked read
218
+ reassembles correctly across `/file/read` calls and keeps every byte value.
207
219
 
208
220
  ## Error / transport contract
209
221
 
@@ -284,14 +296,46 @@ centrs transfer $A upload $BIG centrs-too-big.txt --via native-api --port $API_P
284
296
 
285
297
  Envelope: `ok: false`, `error.code="transport/unsupported-operation"`.
286
298
 
299
+ ## Binary contents (#404)
300
+
301
+ A size check cannot see a same-length substitution, so these compare sha256.
302
+
303
+ ### B1. Binary round-trip over REST
304
+
305
+ ```bash
306
+ centrs transfer $R upload $BIN centrs-bin-rest.bin --username $U --password $P --json
307
+ centrs transfer $R download centrs-bin-rest.bin $TMP/bin-rest.bin --username $U --password $P --json
308
+ ```
309
+
310
+ Upload envelope: `data.bytes=60000`, `data.verified="size"`. `$TMP/bin-rest.bin`
311
+ has the sha256 of `$BIN`.
312
+
313
+ ### B2. Binary round-trip over native-api
314
+
315
+ ```bash
316
+ centrs transfer $A upload $BIN centrs-bin-nv.bin --via native-api --port $API_PORT --username $U --password $P --json
317
+ centrs transfer $A download centrs-bin-nv.bin $TMP/bin-nv.bin --via native-api --port $API_PORT --username $U --password $P --json
318
+ ```
319
+
320
+ Same as B1.
321
+
322
+ ### B3. Chunked binary read over native-api
323
+
324
+ ```bash
325
+ centrs transfer $A download centrs-big.bin $TMP/nv-big.bin --via native-api --port $API_PORT --username $U --password $P --json
326
+ ```
327
+
328
+ Runs after S3 seeds `centrs-big.bin`. `$TMP/nv-big.bin` has the sha256 of `$BIG`.
329
+
287
330
  ## ssh (`--via sftp`)
288
331
 
289
332
  The first SSH consumer: a key-authenticated SFTP round-trip over the host OpenSSH
290
333
  `sftp` subsystem. The harness generates an ed25519 keypair, imports the public
291
334
  half to the CHR user (`/user ssh-keys import`), and drives `--via sftp --ssh-key`
292
335
  against the CHR's forwarded SSH port; `--insecure` accepts the ephemeral host key
293
- (default is `accept-new` trust-on-first-use). RouterOS refuses password login once
294
- a key is set, and centrs's sftp runs `BatchMode=yes`, so key auth is the path.
336
+ (default is `accept-new` trust-on-first-use). centrs's sftp runs `BatchMode=yes`
337
+ and never sends a password, so key auth is the only path (S7); RouterOS's own
338
+ password policy is a separate fact (S8).
295
339
 
296
340
  ### S1. sftp upload (host → device)
297
341
 
@@ -315,8 +359,9 @@ centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $U --ssh-key $K
315
359
  centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $U --ssh-key $KEY --insecure upload $BIG centrs-big.bin --json
316
360
  ```
317
361
 
318
- Envelope: `ok: true`. This is also the seed for example 17 (the same file then
319
- reads back over REST via chunked `/file/read`).
362
+ Envelope: `ok: true`. An sftp download of it has the sha256 of `$BIG`. This is
363
+ also the seed for examples 17 and B3 (the same file then reads back over REST and
364
+ native-api via chunked `/file/read`).
320
365
 
321
366
  ### S4. list over sftp
322
367
 
@@ -336,6 +381,60 @@ centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $U --ssh-key $K
336
381
  Both `ok: true`. On-device `copy` has no SFTP primitive — `--via sftp copy` returns
337
382
  `transport/unsupported-operation` (it stays on rest/native).
338
383
 
384
+ ### S6. A rejected selected key fails despite a trusted agent identity
385
+
386
+ The shared [SSH identity example S5](../execute/examples.md#s5-an-explicit-rejected-key-cannot-fall-back-to-an-unrelated-agent-key)
387
+ runs a real SFTP list with an untrusted `--ssh-key` and a trusted key in a
388
+ private agent. It fails with `auth/failed`; selecting the trusted key or leaving
389
+ the key unset succeeds. Covered by `test/integration/ssh-identity.test.ts`.
390
+
391
+ ### S7. A resolved password does not authenticate batch sftp
392
+
393
+ `$PW_USER` has a password, no SSH key, and the default
394
+ `password-authentication=yes-if-no-key`; a plain OpenSSH password login as that
395
+ user succeeds. The host OpenSSH runs with an empty config and no agent.
396
+
397
+ ```bash
398
+ centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $PW_USER --password $PW_PASS --insecure list --json
399
+ ```
400
+
401
+ Exit 1. Envelope: `ok: false`, `error.code="auth/failed"`,
402
+ `error.context.sshKeySelected=false`, and the resolved meta — `meta.via="ssh"`,
403
+ `meta.target.host`/`port`, `meta.settings.password={kind:"cli",key:"password"}`,
404
+ `meta.operation.auth={username:$PW_USER,passwordProvided:true}`. `tips` is
405
+ `tip/sftp-password-not-used` then `tip/transfer-password-transport`; text output
406
+ lists the same tips. The password appears nowhere in either output.
407
+
408
+ ### S8. RouterOS password policy, and the key path that works
409
+
410
+ After `/user ssh-keys import` installs `$KEY.pub` for `$PW_USER`, the plain
411
+ OpenSSH password login is refused (`Permission denied`), and:
412
+
413
+ ```bash
414
+ centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $PW_USER --ssh-key $KEY --insecure list --json
415
+ ```
416
+
417
+ Envelope: `ok: true`. With `/ip ssh set password-authentication=yes`, the
418
+ password login succeeds again with the key still installed.
419
+
420
+ ### S9. Group policy refusals are not auth failures
421
+
422
+ Each user holds the installed `$KEY`, and a password is passed too:
423
+
424
+ ```bash
425
+ centrs transfer 127.0.0.1 --via sftp --port $SSH_PORT --username $USER_NO_WRITE --password $PW_PASS --ssh-key $KEY --insecure upload $SRC centrs-391.txt --json
426
+ ```
427
+
428
+ | User's group policy | Verb | `error.code` |
429
+ | ------------------- | ---- | ------------ |
430
+ | `ssh,ftp,read` (no `write`) | `upload`, `mkdir` | `routeros/command-failed`, `tips: []` |
431
+ | `ssh,read,write` (no `ftp`) | `list` | `routeros/session-closed` (SSH disconnect reason 7) |
432
+ | `ftp,read,write` (no `ssh`) | `list` | `auth/failed` |
433
+
434
+ Only OpenSSH's pre-login `Permission denied (publickey…)` is `auth/failed`; a
435
+ bare `Permission denied` after login is a policy refusal, so no password tips
436
+ are attached. S7–S9 are covered by `test/integration/ssh-auth-policy.test.ts`.
437
+
339
438
  ## Target selection (fan-out)
340
439
 
341
440
  These exercise the shared target-selection grammar
package/docs/CLI.md CHANGED
@@ -27,12 +27,12 @@ implemented flags live in each command's `commands/<name>/README.md`.
27
27
  Read RouterOS values through the shared core using the selected protocol.
28
28
 
29
29
  ```text
30
- Usage: centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]
30
+ Usage: centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --host <host|url> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]
31
31
  ```
32
32
 
33
33
  | Flag | Value | Description |
34
34
  | ---- | ----- | ----------- |
35
- | `--via` | `<protocol>` | Pin the protocol selector. Defaults to `rest-api` for retrieve. |
35
+ | `--via` | `<protocol>` | Pin the protocol selector. Defaults to `rest-api` for retrieve (`--sample` included), and to `native-api` under `--follow`. |
36
36
  | `--group` | `<name>` | Fan out across every CDB record in the group (repeatable; de-duped by record index). |
37
37
  | `--where` | `<attr>`=`<value>` | Device-class selector over CDB facts + core fields (repeatable, AND-combined). |
38
38
  | `--near` | `<lat>`,`<lon>`,`<radius>` | Geo selector: devices whose GPS is within radius (m/km/mi/ft; bare number = km). Lat-first. |
@@ -41,17 +41,25 @@ Usage: centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <targe
41
41
  | `--default` | | Select the reserved `__default__` record. |
42
42
  | `--quickchr` | `<name>` | Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it). |
43
43
  | `--concurrency` | `<n>` | Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4). |
44
- | `--host` | &lt;host\|url&gt; | Override the resolved host or base URL for the target. |
44
+ | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<target>` it is the target (no CDB lookup); with one it overrides the resolved host. |
45
45
  | `--port` | `<port>` | Override the resolved management port. |
46
46
  | `--username / --user / -u` | `<name>` | RouterOS username (aliases `--user`, `-u`). Falls back to `CENTRS_USERNAME`. |
47
47
  | `--password` | `<secret>` | RouterOS password. Falls back to `CENTRS_PASSWORD`. |
48
- | `--timeout` | &lt;ms\|5s&gt; | Operation timeout. REST currently rejects values above 60s. |
48
+ | `--timeout` | &lt;ms\|5s&gt; | Per-request timeout, separate from --wait's overall deadline. REST currently rejects values above 60s. |
49
49
  | `--attribute` | `<name>` | Project one attribute. May be repeated. |
50
50
  | `--attributes` | `<a,b>` | Project a comma-separated attribute list. |
51
51
  | `--all-attributes` | | Request the RouterOS detail/all-attributes shape. |
52
52
  | `--list-attributes / --list` | | List inspect-derived attributes without running the data call. |
53
- | `--query / --filter` | `<expr>` | RouterOS-side row filter (maps to `.query`). Not implemented yet — returns `validation/not-implemented`. |
54
- | `--format` | &lt;text\|json\|yaml&gt; | Output format for the CLI response. Defaults to text; use --json or --format json for the structured envelope. |
53
+ | `--query / --filter` | `<expr>` | Row filter written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`), run on the router as an API query; repeatable, AND-ed. No `~` or `in`. |
54
+ | `--follow` | | Keep the menu's rows current over native-api `listen`: snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only. |
55
+ | `--sample` | `<interval>` | Read the menu again every interval (with a unit, e.g. `5s`): one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api. |
56
+ | `--wait` | `<deadline>` | Wait for a successful read or a condition until this overall deadline, with a unit (e.g. `2m`). Retries refused, closed, reset and timed-out connections and a down or unreachable host; other errors fail at once. One final envelope; single target. |
57
+ | `--until` | `<expr>` | With --wait: succeed when any selected row matches this --query grammar predicate; repeatable, AND-ed. Returns the matching rows. Already true succeeds on the initial read. |
58
+ | `--until-empty` | | With --wait: succeed on a completed read with zero rows selected by --query. Silence and failed reads never satisfy it. |
59
+ | `--sweep` | `<duration>` | `--follow` only: how often a membership sweep finds removals RouterOS does not send, including silent exits from --query. Needs a unit; default `10s`; `0` turns it off. |
60
+ | `--count` | `<n>` | `--follow`: stop after N live changes (snapshot frames and `synced` do not count). `--sample`: stop after N samples. |
61
+ | `--duration` | `<duration>` | `--follow`/`--sample`: stop after this wall-clock window (e.g. `30s`), counted from the end of validation. |
62
+ | `--format` | &lt;text\|json\|yaml\|ndjson&gt; | Output format for the CLI response. Defaults to text; use --json or --format json for the structured envelope. `ndjson` prints each envelope as one compact line. |
55
63
  | `--json` | | Shortcut for `--format json`. |
56
64
  | `--max-results` | `<bytes>` | Fail instead of printing output larger than the given byte budget. |
57
65
  | `--cdb-file` | `<path>` | Read target credentials from a WinBox CDB file. |
@@ -65,7 +73,7 @@ Usage: centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <targe
65
73
  Run a RouterOS read or write command via native API, REST, or mac-telnet.
66
74
 
67
75
  ```text
68
- Usage: centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --group <name> <command> [flags]
76
+ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --host <host|url> [--] <command> [flags] | centrs execute --group <name> <command> [flags]
69
77
  ```
70
78
 
71
79
  | Flag | Value | Description |
@@ -79,7 +87,7 @@ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> --
79
87
  | `--default` | | Select the reserved `__default__` record. |
80
88
  | `--quickchr` | `<name>` | Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it). |
81
89
  | `--concurrency` | `<n>` | Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4). |
82
- | `--host` | &lt;host\|url&gt; | Override the resolved host or base URL for the target. |
90
+ | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<target>` it is the target (no CDB lookup); with one it overrides the resolved host. |
83
91
  | `--port` | `<port>` | Override the resolved management port. |
84
92
  | `--username / --user / -u` | `<name>` | RouterOS username (aliases `--user`, `-u`). Falls back to `CENTRS_USERNAME`. |
85
93
  | `--password` | `<secret>` | RouterOS password. Falls back to `CENTRS_PASSWORD`. |
@@ -102,7 +110,7 @@ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> --
102
110
  Structured RouterOS API passthrough (gh-api style) over REST or native API.
103
111
 
104
112
  ```text
105
- Usage: centrs api <router> <endpoint> [flags]
113
+ Usage: centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <endpoint> [flags]
106
114
  ```
107
115
 
108
116
  | Flag | Value | Description |
@@ -111,13 +119,13 @@ Usage: centrs api <router> <endpoint> [flags]
111
119
  | `-f / --field` | `<key=value>` | Body field, repeatable; assembled into the JSON body (verbatim string values). |
112
120
  | `-d / --data` | `<json>` | Raw JSON request body. Conflicts with `-f` / `--input`. |
113
121
  | `--input` | &lt;file\|-&gt; | Read the raw JSON body from a file or stdin (`-`). |
114
- | `--query / --filter` | `<expr>` | RouterOS-side row filter, AND-combined, repeatable: name=value, name!=value, name>value, name<value, name. |
115
- | `--raw-query` | `<word>` | Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions. |
122
+ | `--query / --filter` | `<expr>` | Row filter written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`), sent as query words; repeatable, AND-ed. No `~` or `in`. |
123
+ | `--raw-query` | `<word>` | Verbatim RouterOS query word (repeatable), e.g. has-property `name`, absence `-name`, or stack ops `#\|` `#&` `#!`. |
116
124
  | `--attribute / --proplist` | `<a,b>` | Property projection → `.proplist`. |
117
125
  | `--raw` | | Strip the envelope; emit bare RouterOS JSON. Defaults `--validate` to false (an explicit `--validate=true` still runs the gate); does not imply `--yes`. |
118
126
  | `--yes` | | Confirm a mutating (non-read) request in non-interactive runs. |
119
- | `--stream / --listen` | | Follow changes as an NDJSON envelope stream (native-api only; the `/listen` endpoint infers it). Ends with a summary envelope. |
120
- | `--count` | `<n>` | Stop a `--stream` after N change frames. |
127
+ | `--stream` | | Stream the command's native replies (`!re` rows and `!empty`) as NDJSON, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it. |
128
+ | `--count` | `<n>` | Stop a `--stream` after N `!re` rows; `!empty` replies do not count (not the device count= argument). |
121
129
  | `--duration` | `<dur>` | Stop a `--stream` after this wall-clock window (e.g. `5s`). |
122
130
  | `--via` | &lt;rest-api\|native-api&gt; | Pin the transport; no silent downgrade. Default rest-api. |
123
131
  | `--group` | `<name>` | Fan out across every CDB record in the group (repeatable; de-duped by record index). |
@@ -128,7 +136,7 @@ Usage: centrs api <router> <endpoint> [flags]
128
136
  | `--default` | | Select the reserved `__default__` record. |
129
137
  | `--quickchr` | `<name>` | Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it). |
130
138
  | `--concurrency` | `<n>` | Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4). |
131
- | `--host` | &lt;host\|url&gt; | Override the resolved host or base URL for the target. |
139
+ | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<router>` it is the target (no CDB lookup); with one it overrides the resolved host. |
132
140
  | `--port` | `<port>` | Override the resolved management port. |
133
141
  | `--username / --user / -u` | `<name>` | RouterOS username. Falls back to `CENTRS_USERNAME`. |
134
142
  | `--password` | `<secret>` | RouterOS password. Falls back to `CENTRS_PASSWORD`. |
@@ -138,7 +146,7 @@ Usage: centrs api <router> <endpoint> [flags]
138
146
  | `--resolve` | &lt;none\|arp&gt; | Resolve a MAC-address target to an IP via the host ARP cache (default none). |
139
147
  | `--timeout` | `<duration>` | Per-request timeout (for REST, max 60s). |
140
148
  | `--validate / --no-validate` | | Run `/console/inspect` validation before the request (default true; `--validate=false` also accepted). |
141
- | `--format` | &lt;json\|yaml\|text&gt; | Output format. Defaults to json for api; `CENTRS_FORMAT` overrides. |
149
+ | `--format` | &lt;json\|ndjson\|yaml\|text&gt; | Output format. Defaults to json for api; `CENTRS_FORMAT` overrides. |
142
150
  | `--json` | | Shortcut for `--format json`. |
143
151
  | `--verbose` | | Include additional context in text output. |
144
152
 
@@ -147,7 +155,7 @@ Usage: centrs api <router> <endpoint> [flags]
147
155
  Copy files to/from a RouterOS device and manage device files (rest/native/sftp).
148
156
 
149
157
  ```text
150
- Usage: centrs transfer <router> upload <local> [remote] | download <remote> [local] | list [path] | remove <remote> | mkdir <remote> | copy <src> <dst> [flags]
158
+ Usage: centrs transfer <router>|--host <host> upload <local> [remote] | download <remote> [local] | list [path] | remove <remote> | mkdir <remote> | copy <src> <dst> [flags]
151
159
  ```
152
160
 
153
161
  | Flag | Value | Description |
@@ -155,7 +163,7 @@ Usage: centrs transfer <router> upload <local> [remote] | download <remote> [loc
155
163
  | `--via` | `<method>` | Pin the method: rest, native, or sftp (large transfers); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction. |
156
164
  | `--ssh-key` | `<path>` | sftp only: explicit private-key path. Falls back to CENTRS_SSH_KEY / the ssh-agent. |
157
165
  | `--insecure` | | Accept a self-signed TLS cert (https/api-ssl) or a new SSH host key. Default verifies. |
158
- | `--force / --overwrite` | | Replace an existing destination. Default refuses it. |
166
+ | `--force / --overwrite` | | Skip the existing-destination check on the device for `upload`/`mkdir`/`copy` (`upload` then overwrites). Default refuses an existing target. `download` always replaces the local file. |
159
167
  | `--group` | `<name>` | Fan out across every CDB record in the group (repeatable; de-duped by record index). |
160
168
  | `--where` | `<attr>`=`<value>` | Device-class selector over CDB facts + core fields (repeatable, AND-combined). |
161
169
  | `--near` | `<lat>`,`<lon>`,`<radius>` | Geo selector: devices whose GPS is within radius (m/km/mi/ft; bare number = km). Lat-first. |
@@ -169,7 +177,7 @@ Usage: centrs transfer <router> upload <local> [remote] | download <remote> [loc
169
177
  | `--verify / --no-verify` | &lt;size\|checksum\|off&gt; | Post-transfer integrity check. Default size; `--no-verify` skips. |
170
178
  | `--type` | &lt;file\|directory\|disk\|package&gt; | list filter: RouterOS /file row type. |
171
179
  | `--name` | `<glob>` | list filter: file-name glob. |
172
- | `--host` | &lt;host\|url&gt; | Override the resolved host or base URL. |
180
+ | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<router>` before the verb it is the target (no CDB lookup); with one it overrides the resolved host. |
173
181
  | `--port` | `<port>` | Override the resolved management port. |
174
182
  | `--username / --user / -u` | `<name>` | RouterOS username (aliases --user, -u). Falls back to CENTRS_USERNAME. |
175
183
  | `--password` | `<secret>` | RouterOS password. Falls back to CENTRS_PASSWORD. |
@@ -187,13 +195,13 @@ Usage: centrs transfer <router> upload <local> [remote] | download <remote> [loc
187
195
  Open an interactive RouterOS console (ssh or mac-telnet).
188
196
 
189
197
  ```text
190
- Usage: centrs terminal <router> [flags]
198
+ Usage: centrs terminal <router> [flags] | centrs terminal --host <host> [flags]
191
199
  ```
192
200
 
193
201
  | Flag | Value | Description |
194
202
  | ---- | ----- | ----------- |
195
203
  | `--via` | `<method>` | Transport: `ssh` (host target, default) or `mac-telnet` (MAC target, default for a MAC). rest/native have no terminal capability. |
196
- | `--host` | `<host>` | Override the target host (ssh) / UDP delivery host (mac-telnet, default L2 broadcast). |
204
+ | `--host` | `<host>` | ssh: the host to dial (the target when no `<router>` is given; no CDB lookup). mac-telnet: the UDP delivery host (default L2 broadcast). |
197
205
  | `--port` | `<port>` | Override the port: ssh default 22, mac-telnet default 20561. |
198
206
  | `--username / --user / -u` | `<name>` | RouterOS username (aliases --user, -u). Falls back to CENTRS_USERNAME. |
199
207
  | `--password` | `<secret>` | RouterOS password. Falls back to CENTRS_PASSWORD. |
@@ -63,6 +63,22 @@ an ABSTENTION and passes through to the device stage; only a `fail` rejects.
63
63
  that was skipped and why. A reader must always be able to tell "the device
64
64
  accepted it" from "the device was never asked".
65
65
 
66
+ `meta.validation.result` is a verdict, so it is present only when the gate
67
+ produced one. `failed` means the gate refused the request. A failure after the
68
+ gate passed keeps `passed`. When something stopped the gate before it gave a
69
+ verdict (a `transport/*` or `auth/*` failure, a device error that is not a
70
+ gate answer, or a refusal before the gate ran), `result` and the
71
+ `syntax`/`semantic` summaries are absent. Each stage the gate did not reach is
72
+ then `skipped` with `reason: "not reached: <error.code>"` (GH#435). On an
73
+ `execute` rejection, `syntax: true` survives only for a name-level refusal (an
74
+ unknown attribute in a structured command). Any other device or offline
75
+ refusal is `syntax: false`, and `semantic` is then absent (`not-applicable` for
76
+ script input), because the name-level check was never asked. `api`'s
77
+ structured requests run no `:parse`, so their `syntax: false` means "not run",
78
+ even on success. That overload is part of the review below. This is an
79
+ interim shape: the validation meta across commands is reviewed again with live
80
+ `explain` (#236).
81
+
66
82
  `--validate=false` disables **both** stages — one flag, one meaning. A bypass
67
83
  that covered only the device stage would leave a caller stranded behind a
68
84
  stage-1 defect with no escape at all, and the analyzer is demonstrably wrong in
@@ -278,7 +294,10 @@ token cannot spoof them), AND-combined across repeats. A call is in **fan-out
278
294
  mode** when any selector flag is present **or** more than one positional target is
279
295
  given; a plain single-positional call stays single-target. The union is
280
296
  **de-duped by CDB record index** (ad-hoc literal targets by host) and run through
281
- the same per-target pipeline.
297
+ the same per-target pipeline. With no `<router>` positional and no selector, the
298
+ `--host` **flag** stands in for the target: a single target with no CDB lookup,
299
+ so no record credential applies. The env var `CENTRS_HOST` never changes how
300
+ positionals split. Each command README states where its `<router>` slot ends.
282
301
 
283
302
  Fan-out output is the locked **`FanoutData`** envelope: `data = { summary, targets[]
284
303
  }`, where the outer `ok` reports **orchestration** success and a per-target failure
@@ -292,9 +311,9 @@ A multi-target **RouterOS write** (e.g. `execute` / `api` add/set/remove across
292
311
  selection) is gated by **`--yes`**, confirmed **once** up front; when it is missing
293
312
  the error names the blast radius (how many routers). The separate **`--force`**
294
313
  gate is scoped to destructive **`devices` CDB mutations** (e.g. `devices remove`
295
- across a group), not RouterOS writes. `terminal` and single-session `stream` /
296
- `api --stream` are not fan-out surfaces and reject N>1 with
297
- `usage/fanout-not-supported`; `api --raw` is rejected in fan-out mode with
314
+ across a group), not RouterOS writes. `terminal`, `api --stream`,
315
+ `retrieve --follow` and `retrieve --sample` are single-session, not fan-out
316
+ surfaces, and reject N>1 with `usage/fanout-not-supported`; `api --raw` is rejected in fan-out mode with
298
317
  `usage/conflicting-flags` (it strips the per-target envelope).
299
318
 
300
319
  `--where` filters *which devices* by CDB-stored facts; keep it distinct from
@@ -372,8 +391,8 @@ Per-operation preferences, downgrade order in parens:
372
391
 
373
392
  | Operation | Preferred | Downgrade order |
374
393
  | --------- | --------- | --------------- |
375
- | retrieve | rest-api, native-api; snmp for OID/MIB reads (future) | rest-api, native-api |
376
- | api | rest-api, native-api; `--stream` follow native-api only | rest-api, native-api (no silent downgrade) |
394
+ | retrieve | rest-api, native-api (`--sample` too); snmp for OID/MIB reads (future); `--follow` native-api only | rest-api, native-api (`--follow`: no downgrade) |
395
+ | api | rest-api, native-api; `--stream` and `/listen` native-api only | rest-api, native-api (no silent downgrade) |
377
396
  | execute | native-api → rest-api → mac-telnet | native-api, rest-api, mac-telnet, ssh, romon, winbox-terminal |
378
397
  | terminal | ssh | mac-telnet (L2 only when ssh fails or MAC given) |
379
398
  | transfer | size/direction-aware: rest-api/native-api for ≤60 KB writes & all reads (chunked), sftp for large uploads | sftp ⇄ rest/native by size; scp · fetch · ftp explicit-only |
@@ -400,7 +419,7 @@ Rules:
400
419
  - **`measure` (btest) is explicit-only** — never in the auto-select/downgrade
401
420
  chain, never a substitute, and no other command rides it.
402
421
  - Per-command selection **depth** lives in the command READMEs: `api`'s
403
- `--stream` follow/NDJSON contract and the REST-cannot-follow constraint
422
+ literal `--stream`/NDJSON contract and the REST-cannot-follow constraint
404
423
  (`commands/api/README.md`), `transfer`'s size/direction-aware method
405
424
  selection and the explicit-only `scp`/`ftp`/`fetch`
406
425
  (`commands/transfer/README.md`), SNMP's MIB-cache
@@ -432,9 +451,10 @@ ecosystem norm* rather than one literal value:
432
451
 
433
452
  SSH identity selection: the `sshKey` setting (`--ssh-key`/`CENTRS_SSH_KEY`/CDB
434
453
  `ssh-key=`) carries a private-key **path only** — never key material; when unset,
435
- the host SSH agent / `~/.ssh/config` is used. RouterOS refuses password login once
436
- an SSH key is set for a user (`password-authentication=yes-if-no-key`, the
437
- default), so key auth is the normal sftp path.
454
+ the host SSH agent / `~/.ssh/config` is used. Batch SSH clients (sftp, `execute
455
+ --via ssh`) run `BatchMode=yes` and never send a password, so key auth is the
456
+ only batch path. Separately, RouterOS refuses SSH password login once a user has
457
+ a key (`/ip/ssh password-authentication=yes-if-no-key`, the default).
438
458
 
439
459
  ## Done definition
440
460
 
package/docs/MATRIX.md CHANGED
@@ -60,6 +60,33 @@ and implementation sequencing in the linked issues.
60
60
  structured operation surface is `api`.
61
61
  - `api --stream` absorbs the former `stream`/`tail` surface; native-api stream
62
62
  details are in `commands/api/README.md` and `src/protocols/native-api.ts`.
63
+ #399 extends it to incremental command replies and truthful terminal outcomes.
64
+ #402 makes it literal: following is the `<menu>/listen` endpoint, every wire
65
+ reply (`!empty` included) is a line, and what RouterOS will not report
66
+ (filtered or projected change feeds) is a tip, not a rewrite.
67
+ - `retrieve --follow` (#396) is the stateful follow over native-api: A1
68
+ bootstrap, `synced`, live frames, and an `.id` sweep for removals RouterOS
69
+ does not send. Single-target; `CHR-passed` by
70
+ `test/integration/retrieve-follow.test.ts` (FL1–FL9, 7.23.7 and 7.24.5).
71
+ Filtered follow (#397) uses device-evaluated changed-id membership, guarded
72
+ against stale replies; `test/integration/retrieve-filtered-follow.test.ts`
73
+ covers FQ1–FQ5 on 7.23.8 and 7.24.5, including projected predicates, both membership directions,
74
+ minimal deletes, silent predicate exits and bootstrap/live churn.
75
+ - `retrieve --sample <interval>` repeats the one-shot read on a timer, one
76
+ line per complete read, over rest-api or native-api: the answer for counters
77
+ and state `listen` never reports. RouterOS's `print interval=` was measured
78
+ and not used (no end-of-tick marker). Single-target; `CHR-passed` by
79
+ `test/integration/retrieve-sample.test.ts` (SA1–SA6, 7.23.7 and 7.24.5).
80
+ - `retrieve --query` (#397) filters rows on the router with the `print where`
81
+ grammar, compiled to the API query; `api --query` shares it. One-shot,
82
+ `--sample` and fan-out; `CHR-passed` by
83
+ `test/integration/retrieve-query.test.ts` (QY1–QY7, QA1–QA2, 7.23.7 and
84
+ 7.24.5), whose QY1 checks the rows against RouterOS's own `find where`.
85
+ - `retrieve --wait` / `--until` / `--until-empty` (#364) return one bounded
86
+ readiness/condition result over REST or native-api, with elapsed time,
87
+ attempts and completed observations. `test/integration/retrieve-wait.test.ts`
88
+ covers WT1–WT7 on 7.23.8 and 7.24.5, including real reboot readiness and a
89
+ two-CHR OSPF adjacency.
63
90
  - `discover / mndp` is grounded by `commands/discover/README.md`,
64
91
  `src/data/mndp.ts`, `src/data/mndp-cache.ts`, and
65
92
  `test/integration/discover.test.ts`.
@@ -150,7 +177,17 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
150
177
 
151
178
  ## Current priority
152
179
 
153
- **Tightening validation as a whole** is the selected track, in the three-step
180
+ **First: the native-api / NDJSON line** (maintainer decision on
181
+ [#396](https://github.com/tikoci/centrs/issues/396), 2026-10-07): agents in the
182
+ field poll with `sleep` where a native follow would answer. In order:
183
+ (1) done: #402, literal `api --stream` with `!empty` frames and
184
+ `--format ndjson`; (2) done: `retrieve --follow` (#396, unfiltered);
185
+ (3) done: `retrieve --sample`; (4) #397: one-shot `retrieve --query` done,
186
+ filtered `--follow` done; #364 (`--wait`/`--until`, same grammar) done;
187
+ (5) #398 and #401. Each slice's contract lives in its command
188
+ README. Live `explain` (below) resumes after this line.
189
+
190
+ **Tightening validation as a whole** is the next track, in the three-step
154
191
  order agreed on [#236](https://github.com/tikoci/centrs/issues/236):
155
192
 
156
193
  1. **Done** — fix the offline false rejects so the analyzer can be trusted as a
@@ -59,10 +59,12 @@ Codes are slash-namespaced `family/slug`. The families and where they originate:
59
59
  - `transport/*` — connection, TLS, DNS, and timeout plumbing.
60
60
  - `usage/*` — how a command was invoked (arity, conflicting flags, confirmation).
61
61
  - `validation/*` — the canonicalize → validate gate (`:parse` + `/console/inspect`).
62
+ - `wait/*` — readiness/condition deadlines and cancellation (`retrieveWait`).
62
63
 
63
64
  The hand-enriched pages (richer trigger strings and remediation) currently are
64
65
  the `routeros/*` set produced by `src/core/routeros-errors.ts` and the
65
- `target/*` MAC-resolution pages; the rest are generated stubs to be enriched as
66
+ `target/*` MAC-resolution pages and `wait/*` deadline/cancellation pages;
67
+ the rest are generated stubs to be enriched as
66
68
  each code is grounded.
67
69
 
68
70
  ## RouterOS-fault mapping (`routeros/*`)
@@ -0,0 +1,36 @@
1
+ # `input/invalid-query`
2
+
3
+ A `--query` expression does not parse as a `print where` condition.
4
+
5
+ ## When it happens
6
+
7
+ `retrieve --query` and `api --query` take what you would write after
8
+ `print where`: `name=ether1`, `mtu>=1500 and !dynamic`,
9
+ `(type=ether or type=vlan)`. centrs parses it before anything is sent, then
10
+ runs it on the router as an API query. The error's `context.offset` points at
11
+ the problem in `context.query`.
12
+
13
+ centrs also refuses spellings that RouterOS reads differently from how they
14
+ look, or does not accept:
15
+
16
+ - `!name=value` is `(!name)=value` to RouterOS, which matches by accident.
17
+ Write `!(name=value)` or `name!=value`.
18
+ - `not` is not a RouterOS operator. Use `!`.
19
+ - Conditions separated only by a space. Join them with `and` or `or`.
20
+ - An unquoted `=` inside a value. RouterOS reads `comment=x=yes` as
21
+ `(comment=x)=yes`, and `comment=x!=y` matches nothing. Quote it:
22
+ `comment="x=yes"`.
23
+ - A space after the operator (`comment= x`), which RouterOS rejects too.
24
+ - A string escape RouterOS does not have, such as lowercase hex `\0a` (hex is
25
+ two uppercase digits; `\ff` is `\f` then `f`), or a hex byte above `\7F`,
26
+ which a UTF-8 query value cannot carry.
27
+
28
+ A bare name (`disabled`, `comment`) means what it means after `where`: `yes`
29
+ for a boolean and "is set" for anything else. Only the router knows which a
30
+ property is, so with `--validate=false` a bare name fails here too.
31
+
32
+ ## Fix
33
+
34
+ - Quote values that contain spaces: `comment="uplink a"`.
35
+ - Close every `(` and `"`.
36
+ - Write a boolean out as `disabled=yes` if validation is off.
@@ -0,0 +1,20 @@
1
+ # `input/local-destination`
2
+
3
+ A local download destination cannot be written (missing parent directory, a directory, or no permission).
4
+
5
+ `transfer download` checks the local destination before it reads anything
6
+ from the device. The parent directory must already exist, because centrs does
7
+ not create directories, and the destination itself must not be a directory.
8
+ A path that cannot be checked at all (no permission on a directory along the
9
+ way, a file used as a directory, a symlink loop) fails the same way. A write
10
+ that still fails afterwards (no permission, the directory removed
11
+ mid-transfer) reports this code too, with the operating-system error as the
12
+ cause.
13
+
14
+ ## Fix
15
+
16
+ - Create the parent directory first: `mkdir -p DIR`, then retry the download.
17
+ - Name a file, not a directory: `download fw.rsc ./backups/fw.rsc`, not
18
+ `download fw.rsc ./backups/`.
19
+ - Pass `-` as the local path to write the bytes to stdout instead.
20
+ - For a fan-out download, `--out-dir` must be an existing directory.
@@ -0,0 +1,19 @@
1
+ # `input/unsupported-query`
2
+
3
+ A `--query` uses something the RouterOS API query cannot run.
4
+
5
+ ## When it happens
6
+
7
+ `--query` runs on the router as an API query (native `?` words, REST
8
+ `.query`), and that query language has no regex (`~`), no prefix membership
9
+ (`in`), and no script evaluation. A value starting with `$`, `[` or `{`, or a
10
+ `$` inside a quoted value, would need RouterOS's script evaluator. centrs
11
+ refuses these rather than filtering some other way and calling it a router
12
+ filter.
13
+
14
+ ## Fix
15
+
16
+ - Read the rows without that condition and filter them yourself.
17
+ - Or run the console form, which returns console text rather than rows:
18
+ `centrs execute <router> '/ip/address/print where interface~"^ether"'`.
19
+ - For a literal `$` in a quoted value, escape it as `\$`.
@@ -0,0 +1,13 @@
1
+ # `internal/byte-string`
2
+
3
+ A binary RouterOS word held a character that is not a byte.
4
+
5
+ ## Fix
6
+
7
+ This is a centrs bug; report it with the command that triggered it.
8
+
9
+ `transfer` moves `/file` contents over REST and native-api as byte strings,
10
+ one string code unit per byte (#404). A code unit above U+00FF has no single
11
+ byte, so centrs refuses the request before it reaches the router rather than
12
+ silently keeping the low byte. See
13
+ [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error contract.
@@ -18,6 +18,12 @@ Like the other typed-value codes here, this isn't `settings`-exclusive:
18
18
  flag, so the same failure shape shows up anywhere a duration string is
19
19
  resolved, not just through `settings set`.
20
20
 
21
+ `retrieve --sample <interval>` and `retrieve --follow --sweep <period>` are
22
+ stricter: the value needs a unit (`5s`, `500ms`, `1m`). `--sample` must be
23
+ above zero; `--sweep 0` is allowed and turns the sweep off. A bare `--sample 5` fails here
24
+ because RouterOS reads `interval=5` as five seconds while centrs reads a bare
25
+ number as milliseconds; pass the unit you mean.
26
+
21
27
  One write detail worth knowing: `settings set timeout` writes the
22
28
  **canonical parsed milliseconds**, not the raw suffixed string you typed —
23
29
  `centrs settings set timeout 5s` writes `CENTRS_TIMEOUT=5000` to
@@ -0,0 +1,21 @@
1
+ # `transport/stream-overflow`
2
+
3
+ More stream replies were waiting unread than the buffer allows, so centrs ended
4
+ the stream rather than drop any.
5
+
6
+ ## When it happens
7
+
8
+ `centrs retrieve <router> <menu> --follow` buffers changes between RouterOS and
9
+ the reader, up to 50,000. The buffer fills when the reader stops reading (a
10
+ stalled pipe), or when a burst arrives while a long snapshot is still loading.
11
+ centrs never drops a change silently, because the state you hold would then
12
+ look current while being wrong. It ends the follow with this error instead, as
13
+ the failed summary (`stopReason: "transport-error"`).
14
+
15
+ ## Fix
16
+
17
+ - The state you built from the lines is stale. Follow again: the new follow
18
+ starts with a fresh snapshot.
19
+ - Read lines as they arrive. Don't buffer the whole output before processing.
20
+ - For a very busy menu, follow a narrower menu (for example `/interface/vlan`
21
+ instead of `/interface`).
@@ -0,0 +1,20 @@
1
+ # `usage/removed-flag`
2
+
3
+ A flag or request field was removed; the error names its replacement.
4
+
5
+ ## Fix
6
+
7
+ Run the replacement printed in the error's remediation.
8
+
9
+ `api --listen` (and the library's `ApiRequest.listen`) is the current case
10
+ (#402). It meant "stream the replies" and also turned a GET of a menu into a
11
+ change subscription. `api` now sends the command as typed:
12
+
13
+ - To follow changes, request the menu's `listen`:
14
+ `centrs api <router> ip/address/listen`. A `/listen` endpoint implies
15
+ `--stream` and native-api.
16
+ - To stream any other command's replies, use `--stream`
17
+ (`ApiRequest.stream` / `apiStream()` in the library).
18
+
19
+ See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
20
+ contract.