@tikoci/centrs 0.1.6 → 0.1.7

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 (38) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +8 -4
  3. package/commands/api/README.md +21 -10
  4. package/commands/api/examples.md +70 -2
  5. package/commands/devices/README.md +10 -1
  6. package/commands/devices/examples.md +33 -2
  7. package/commands/execute/README.md +29 -15
  8. package/commands/execute/examples.md +89 -0
  9. package/commands/explain/README.md +12 -1
  10. package/commands/mcp/README.md +2 -1
  11. package/commands/retrieve/examples.md +20 -0
  12. package/docs/CLI.md +1 -1
  13. package/docs/CONSTITUTION.md +11 -6
  14. package/docs/errors/cdb/create-failed.md +20 -0
  15. package/docs/errors/cdb/file-exists.md +23 -0
  16. package/docs/errors/input/invalid-target.md +25 -3
  17. package/docs/errors/routeros/invalid-value.md +3 -2
  18. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  19. package/package.json +1 -1
  20. package/src/api.ts +109 -25
  21. package/src/cli/devices.ts +16 -3
  22. package/src/core/error-catalog.ts +14 -0
  23. package/src/core/execute-command.ts +313 -6
  24. package/src/core/inspect.ts +22 -22
  25. package/src/core/routeros-errors.ts +66 -0
  26. package/src/data/winbox-cdb-write.ts +22 -8
  27. package/src/devices.ts +142 -28
  28. package/src/execute-fanout.ts +1 -1
  29. package/src/execute.ts +22 -51
  30. package/src/explain/catalog.ts +8 -8
  31. package/src/explain/menus.ts +4 -4
  32. package/src/index.ts +2 -0
  33. package/src/mcp/tools.ts +1 -1
  34. package/src/protocols/adapter.ts +12 -1
  35. package/src/protocols/mac-telnet-console.ts +6 -4
  36. package/src/protocols/native-api.ts +29 -3
  37. package/src/resolver/target.ts +32 -12
  38. package/src/retrieve.ts +17 -4
package/CHANGELOG.md CHANGED
@@ -6,7 +6,114 @@ The product evolves as a stream of changes; there are no alpha gates.
6
6
  Per-feature status is tracked in `docs/MATRIX.md`. Use this file for
7
7
  documenting cross-cutting shifts that affect contributors and consumers.
8
8
 
9
- ## Unreleased
9
+ ## 0.1.7 — 2026-09-27
10
+
11
+ ### Added
12
+
13
+ - **`centrs devices init` creates an empty CDB, `git init`-style (#376).**
14
+ Bootstrapping a device book previously meant conjuring the file some other
15
+ way. `centrs devices init [--cdb-file PATH]` (or `CENTRS_CDB_FILE`, or the
16
+ default path) creates the parent directories and an exclusive `0600` file.
17
+ **It never clobbers**, so a bootstrap script may run it unconditionally: an
18
+ existing readable CDB succeeds with a `cdb/file-exists` warning and nothing
19
+ is written. An existing file that fails to load (`cdb/parse-failed`,
20
+ `cdb/password-required`) reports that error and is left untouched, and a
21
+ location that cannot be written fails with the new `cdb/create-failed`. CLI
22
+ and library (`initCdb`) only; not exposed on the MCP devices tool.
23
+
24
+ - **`devices list` reports the lookup keys it matches on (#378).** Rows now
25
+ carry `identity` / `mac` / `ip` when the comment sets them, with
26
+ `comment-kv` provenance in `sources`, and the text table gains an `IDENTITY`
27
+ column. `comment` itself stays `show`-only.
28
+
29
+ ### Fixed
30
+
31
+ - **Write-shaped commands now require confirmation, on every transport
32
+ (#365, #366).** Write authorization was entangled with script-vs-structured
33
+ transport selection, so several shapes reached a device without passing the
34
+ `--yes` gate: selectors, positional ids, non-CRUD verbs, root mutators,
35
+ indirect execution and relative menu writes. Authorization is now decided
36
+ independently of how the command is carried. Read spellings are unaffected —
37
+ the verb is the first known verb among the path's last segment and the bare
38
+ words following the path token, so `/system identity print` and
39
+ `:put [/system identity get name]` stay reads, while a statement with no
40
+ recognizable verb still fails closed. Caller-addressed API `/execute` script
41
+ strings additionally run offline analysis, then live `:parse`, then execute;
42
+ structured API requests remain on `/console/inspect`. This advances the
43
+ synchronous half of #154 without closing it — caller-controlled `file=`,
44
+ `as-string` and async job-id semantics remain open there.
45
+
46
+ - **RouterOS rejections returned inside a successful transport envelope are no
47
+ longer read as success.** `/execute` can answer 200 while the device's own
48
+ text says it refused. Those are now classified as runtime failures, with the
49
+ passed validation-stage history preserved so the envelope still shows what
50
+ each stage concluded.
51
+
52
+ - **Device-stage validation missed RouterOS's bare `missing …` diagnostics
53
+ (#375).** `classifyParseResult` matched `syntax error`, `bad command name`
54
+ and `expected …` from `:put [:parse …]`, but not `missing closing brace` or
55
+ `missing value for where` — so the device stage reported those rejections as
56
+ a pass and the command ran. `/ip/address/find where=` passes offline, which
57
+ made the device classifier its only gate. Replaying every bare diagnostic in
58
+ the pinned corpus across 7.20.8 → 7.25beta3 finds exactly seven heads, and
59
+ the two `missing …` forms were the only misses; both are now recognized, with
60
+ the device's own position in `error.context.detail`. Echoed script text is
61
+ still not a verdict.
62
+
63
+ - **A stream's `/cancel` acknowledgement is now bounded (#385).** `api
64
+ --stream` stopped by `--duration` or Ctrl-C sent `/cancel` and then waited
65
+ with no bound, so a connected router that never answered held the CLI open
66
+ forever — `backend.close()` in the outer `finally` was unreachable while that
67
+ wait was pending. The wait is now bounded by the request `--timeout`, the
68
+ same bound as every other reply wait; past it the listen ends locally and the
69
+ session closes. The summary keeps its real `stopReason` and adds a
70
+ `transport/cancel-unacknowledged` **warning**, so the outcome never claims an
71
+ acknowledgement that did not arrive. A cooperating router is unchanged.
72
+
73
+ - **`retrieve --attributes` rejected real singleton properties (#377).**
74
+ `isKnownSingletonPath` knew only `/system/resource` and `/system/identity`,
75
+ so every other singleton (`/tool/romon`, `/system/routerboard`, `/ip/dns`, …)
76
+ was inspected as a list menu. `print,proplist` comes back empty on a
77
+ singleton, so validation fell back to `print`'s own flags (`as-value`,
78
+ `file`, `interval`, …) and offered those as the attribute list. The singleton
79
+ bit now comes from the device: a singleton's `get` takes only `value-name`,
80
+ while a list menu's `get` also takes `number`. The hardcoded pair survives
81
+ only as the `--validate=false` fallback, which skips inspect. Costs one extra
82
+ `request=child` for a menu that has `get`.
83
+
84
+ - **Completion candidates mixed help text and syntax rows into property names
85
+ (#380).** `extractCompletionNames` read `completion`/`name`/`value`/`text`
86
+ from every row, so the help string ("DNS cache size in kB") and the
87
+ `show: "false"` syntax rows (`[`, `(`, `$`, `"`, `*`, `<value>`) were offered
88
+ as attributes — which is why `--attributes '$'` passed validation. Rows carry
89
+ only `completion`/`offset`/`preference`/`show`/`style`/`text`/`type` on every
90
+ build recorded 7.9.2 → 7.25beta3, so `name`/`value` never matched anything.
91
+ On CHR 7.23.7, `show: "true"` is exactly the property set. The exported
92
+ `InspectCompletionItem` drops the never-present `name`/`value` and adds
93
+ `show`/`style`/`offset`/`preference`.
94
+
95
+ - **An unparseable `<router>` crashed instead of reporting (#381).**
96
+ `parseHostCandidate` passed the target straight to `new URL` — the only
97
+ `new URL` on user input in `src/` — so with the default CDB, `quickchr:NAME`,
98
+ `a b` or `bad:host:name` failed every command with a `TypeError: Invalid URL`
99
+ surfaced as `internal/unhandled`. It now raises `input/invalid-target` and
100
+ names the valid target forms; a `quickchr:NAME` guess is told to use
101
+ `--quickchr NAME`. With an explicit CDB the same input already stopped at a
102
+ classified `cdb/not-found-target`, so that path is unchanged.
103
+
104
+ ### Changed
105
+
106
+ - **The pinned restraml inspect trees advance to the live channel builds.** The
107
+ pins named "current stable" and "next beta" while pointing a patch behind
108
+ both: stable 7.24.2 → 7.24.4 and development 7.25beta3 → 7.25beta5. History
109
+ anchors 7.10.2 and 7.16 are untouched and both current versions stay
110
+ architecture-paired. The refresh is structurally a no-op — the inspect union
111
+ holds at 617 menus and the catalog at 1,120 paths, with no path added or
112
+ removed and no kind change. What moved is current-only publication: the CLI
113
+ Reference grew 1,053 → 1,055 pages (1,060 → 1,062 entries) and now documents
114
+ `/interface/ethernet/pon` and `/ip/ssh/known-hosts`, so both move `inspect` →
115
+ `both` (961 `both`, 65 `inspect`, 94 `published`). Zero
116
+ navigation-vs-command contradictions, unchanged.
10
117
 
11
118
  ## 0.1.6 — 2026-09-15
12
119
 
package/README.md CHANGED
@@ -11,9 +11,13 @@ installing centrs or connecting to a router:
11
11
  bunx @tikoci/centrs@next explain '/ip/address print' --json
12
12
  ```
13
13
 
14
- Use `bunx @tikoci/centrs@next --help` to inspect the full preview CLI. The
15
- explicit `@next` matters: `0.1.x` preview releases do not move npm's `latest`
16
- tag.
14
+ Use `bunx @tikoci/centrs@next --help` to inspect the full preview CLI. Since
15
+ 0.1.6 the dist-tag follows standard SemVer semantics rather than the old
16
+ even/odd-minor rule: a plain version (`0.1.6`) publishes to `latest` and carries
17
+ `next` forward with it, while a pre-release suffix (`0.1.7-next.0`) publishes to
18
+ `next` only and never moves `latest`. So `@next` is the leading edge and can
19
+ equal `@latest` between cuts — `npm view @tikoci/centrs dist-tags` is the
20
+ authority.
17
21
 
18
22
  The full flag reference for every command is generated from the CLI metadata
19
23
  into [`docs/CLI.md`](docs/CLI.md); per-command behavior lives in
@@ -58,7 +62,7 @@ product; without them this would just be a worse `curl`.
58
62
  | `execute` | Run RouterOS CLI-shaped read/write commands (add/set/remove) over native API/REST/L2 surfaces. |
59
63
  | `api` | Structured RouterOS API passthrough (gh-api style): one command per operation, structured in/out, can write, over REST or native API. Open-ended follow is `api … --stream` (native API; NDJSON). |
60
64
  | `terminal` | Open an interactive console, primarily SSH or MAC-Telnet. |
61
- | `check` | Probe reachability and management protocol availability. |
65
+ | `check` | Probe reachability and management protocol availability. **Designed, not implemented** — see `docs/MATRIX.md`. |
62
66
  | `explain` | Analyze a RouterOS command before running it: canonical form, syntax diagnostics, and what is valid at a path. Offline analysis runs today; the live-device probes are designed. |
63
67
  | `devices` | View and maintain the CDB-backed device registry (the only writer). |
64
68
  | `discover` | Discover MNDP neighbors and optionally save them into the CDB. |
@@ -112,15 +112,18 @@ gate is `/console/inspect` — **not** `:put [:parse]`:
112
112
  does) answers normally on the same build. See the 7.12.2 finding in
113
113
  `AGENTS.md` (GH#343).
114
114
  - **Carve-out:** a script-shaped `POST /rest/execute` is a CLI string, not a path,
115
- so the inspect gate is `not-applicable` (`meta.validation.semantic`); RouterOS
116
- re-validates on the run. Until GH#354 that carve-out meant the script was sent
117
- with *no* preflight at all. It is now gated by the offline stage (constitution:
118
- validation) — the same analyzer `explain` publishes, run over the `script`
119
- field with no connection, so a syntax fault is a byte span in
120
- `error.context.span` instead of a round trip. `meta.validation.stages[]` shows
121
- the offline stage `passed` and the device stage `skipped` with its reason. A
122
- missing or blank `script` is still `input/invalid-command`, not a syntax
123
- rejection.
115
+ so structured inspect is `not-applicable` (`meta.validation.semantic`). It
116
+ follows the CLI-string pipeline instead: offline analysis, then live
117
+ `:put [:parse ...]`, then run. An offline rejection is still a byte span with
118
+ no connection; an offline pass or abstention continues to the device stage.
119
+ `meta.validation.stages[]` reports both decisions. A missing or blank `script`
120
+ is still `input/invalid-command`, not a syntax rejection. Validation passing
121
+ is not the run succeeding: `as-string` makes the console's own text the reply
122
+ body, so a runtime rejection arrives as an HTTP-200 `ret`. `/execute` output
123
+ is normalized through the same grounded result table `execute` uses, so
124
+ `no such item` fails the envelope with a `routeros/*` code while the stages
125
+ stay `passed`. Ordinary output that merely contains a fault string is
126
+ unaffected — the result matchers are anchored, not substring.
124
127
  - Structured path requests are **not** offline-gated. The input is a path plus a
125
128
  body, not a CLI string, and an offline *path* gate collides with GH#211's
126
129
  unlisted-path decision. On the success path `meta.validation.stages[]` is
@@ -186,7 +189,15 @@ RouterOS produces multi-frame output two ways, and `api` treats them differently
186
189
  deletion's `.dead=true` flag is preserved); the stream ends with a summary
187
190
  envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
188
191
  `transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
189
- bound it; Ctrl-C stops and still emits the summary. `--via rest-api --stream` →
192
+ bound it; Ctrl-C stops and still emits the summary. The `--duration` window
193
+ starts once validation is done, so it excludes validation (and the connect and
194
+ login validation needs). With `--no-validate`, connect and login happen inside
195
+ the window. Stopping on `--duration` or Ctrl-C sends RouterOS `/cancel`, and
196
+ centrs waits up to `--timeout` for the router to acknowledge it. A router that
197
+ never does gets its session closed locally, and the summary carries a
198
+ `transport/cancel-unacknowledged` warning (#385). Reaching `--count` sends
199
+ `/cancel` best-effort and closes the session without waiting, so it never
200
+ carries that warning (examples L5). `--via rest-api --stream` →
190
201
  `transport/capability-unsupported`. The exit code reflects whether the stream
191
202
  *started* cleanly, not whether every frame was `ok`.
192
203
 
@@ -156,7 +156,8 @@ centrs api $R execute -X POST -f script=':put [/system/identity/get name]' --use
156
156
 
157
157
  Envelope: `ok: true`, `data` is string-shaped and contains the CHR identity;
158
158
  `meta.validation.semantic=not-applicable` (script, not a path) and
159
- `meta.validation.stages` is `offline: passed` then `device: skipped`.
159
+ `meta.validation.stages` is `offline: passed` then live `device: passed` from
160
+ `:put [:parse ...]`.
160
161
 
161
162
  ### 16b. The `/execute` script is gated offline (GH#354)
162
163
 
@@ -173,6 +174,31 @@ Envelope: `ok: false`, `error.code=validation/syntax`,
173
174
  `error.context.span={"start":5,"end":6}`, and **not** a `transport/*` code.
174
175
  `meta.validation.stages` is `offline: failed` then `device: skipped`.
175
176
 
177
+ ### 16c. Offline pass continues to live `:parse`
178
+
179
+ ```bash
180
+ centrs api $R execute -X POST -f script='/ip/address/add no-such-arg=x' --username $U --password $P --yes
181
+ ```
182
+
183
+ Envelope: `ok: false` with a device validation code
184
+ (`validation/unknown-attribute`, or `validation/syntax` on older RouterOS), and
185
+ `meta.validation.stages` is `offline: passed` then `device: failed`. Offline
186
+ acceptance remains `runtimeAcceptance: not-proven`; it never suppresses the live
187
+ stage.
188
+
189
+ ### 16d. A runtime rejection in `/execute` output is not `ok: true`
190
+
191
+ ```bash
192
+ centrs api $R execute -X POST -f script='/ip/service/set www-ssl certificate=nope' --username $U --password $P --yes
193
+ ```
194
+
195
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`. `as-string` makes
196
+ the console's own text the reply body, so RouterOS carries this rejection in an
197
+ HTTP-200 `ret`; the validation gate stays `passed` because it did pass — the
198
+ command was rejected at run, not at validation. Ordinary output that merely
199
+ mentions a fault string (`:put "status: no such item appears in help"`) stays
200
+ `ok: true`.
201
+
176
202
  ### 17. `--via rest-api --listen` is rejected
177
203
 
178
204
  ```bash
@@ -308,7 +334,28 @@ stdout is the bare rest-style array (string values); exit code 0.
308
334
  centrs api $A execute -X POST -f script=':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P --yes
309
335
  ```
310
336
 
311
- Envelope: `ok: true`, `data` contains the CHR identity.
337
+ Envelope: `ok: true`, `data` contains the CHR identity and validation reports
338
+ offline plus live `:parse` stages passed.
339
+
340
+ ### N8b. Native `/execute` also rejects at live `:parse`
341
+
342
+ ```bash
343
+ centrs api $A execute -X POST -f script='/ip/address/add no-such-arg=x' --via native-api --port $API_PORT --username $U --password $P --yes
344
+ ```
345
+
346
+ Envelope: `ok: false`; the accepted version-specific validation code is
347
+ `validation/unknown-attribute` or `validation/syntax`, with stages
348
+ `offline: passed`, `device: failed`.
349
+
350
+ ### N8c. Native `/execute` runtime rejection fails the envelope
351
+
352
+ ```bash
353
+ centrs api $A execute -X POST -f script='/ip/service/set www-ssl certificate=nope' --via native-api --port $API_PORT --username $U --password $P --yes
354
+ ```
355
+
356
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`, with the validation
357
+ stages still `passed` — the same normalization as REST, since both adapters
358
+ surface the `as-string` console text as `data`.
312
359
 
313
360
  ## listen / `--stream` (native-api only)
314
361
 
@@ -356,6 +403,27 @@ centrs api $A ip/address --stream --duration 2s --via native-api --port $API_POR
356
403
  With no change during the window, the stream ends after ~2 s with a summary
357
404
  envelope whose `data.stopReason=duration-elapsed`. Exit code 0.
358
405
 
406
+ ### L5. A router that ignores `/cancel` cannot hold the process open
407
+
408
+ ```bash
409
+ centrs api $A ip/address --stream --duration 200ms --timeout 1s --via native-api --port $API_PORT --username $U --password $P
410
+ ```
411
+
412
+ A stream stopped by `--duration` or Ctrl-C sends `/cancel` and waits up to
413
+ `--timeout` for the router's acknowledgement (the interrupted `!trap` and
414
+ `!done`). If none arrives, centrs closes the session itself. The summary still
415
+ reports its stop reason (`duration-elapsed` or `interrupted`), exit code is 0,
416
+ and `warnings` carries `transport/cancel-unacknowledged`. A router that answers
417
+ inside the window gets no warning. `--count` sends `/cancel` without waiting,
418
+ so it exits at once and never warns (#385).
419
+
420
+ RouterOS on CHR always acknowledges `/cancel`, so this example runs against a
421
+ loopback native-API peer that ignores it, as a real CLI process:
422
+ `test/integration/api-stream-cancel.test.ts` covers `--duration`, SIGINT,
423
+ `--count` and the cooperative control. The session-level cases (silent peer,
424
+ chatty peer, trap without `!done`, close after cancel) are in
425
+ `test/unit/native-api.test.ts`.
426
+
359
427
  ## fanout (multi-target, F…)
360
428
 
361
429
  These run against a CDB fixture with two records sharing group `$G`: record 0 is
@@ -332,6 +332,7 @@ runs produce stable diffs.
332
332
  ## Subcommands
333
333
 
334
334
  ```text
335
+ centrs devices init # create an empty CDB at the resolved path
335
336
  centrs devices list [--group G] [--where attr=value]
336
337
  [--near lat,lon,radius] [--bbox south,west,north,east]
337
338
  [--format json|yaml|text]
@@ -361,8 +362,16 @@ clack-style prompt used by `@tikoci/quickchr`); it is **not** a separate
361
362
  field-editing verb, and invoking it today returns `usage/not-implemented`. There
362
363
  is no `update`.
363
364
 
365
+ - `init` creates an empty open CDB at the resolved path (`--cdb-file`,
366
+ `CENTRS_CDB_FILE`, or the default), with parent directories and mode `0600`.
367
+ It never overwrites: on an existing, readable CDB it succeeds with a
368
+ `cdb/file-exists` warning and writes nothing. An explicit path is created only
369
+ by `init`; every other subcommand reports `cdb/not-found` for it (typo
370
+ protection), and that error names `init`.
364
371
  - `list` (alias `print`) shows resolved targets, their record type, group, and
365
- a one-line provenance summary. No network IO. `--where attr=value`
372
+ a one-line provenance summary. Each row carries the `identity=`/`mac=`/`ip=`
373
+ lookup keys it resolves by (when set), so one call maps names to records. No
374
+ network IO. `--where attr=value`
366
375
  (repeatable, AND-combined) filters by CDB-stored facts/comment-kv plus core
367
376
  record fields (`target`/`group`/`identity`/`mac`) — e.g. `--where
368
377
  lat=37.7749`. `--near lat,lon,radius` and `--bbox south,west,north,east`
@@ -29,7 +29,8 @@ centrs devices list --cdb-file $CDB
29
29
  ```
30
30
 
31
31
  Envelope: `ok: true`, `data` is an array of `{ target, recordType, group?,
32
- user, sources }`. No `meta.timing.request`.
32
+ user, identity?, mac?, ip?, sources }` — the lookup keys appear only when the
33
+ record's comment sets them. No `meta.timing.request`.
33
34
 
34
35
  ### 2. List a single group
35
36
 
@@ -46,7 +47,8 @@ valid success.
46
47
  centrs devices list --cdb-file /tmp/does-not-exist.cdb
47
48
  ```
48
49
 
49
- Errors with `cdb/not-found`. `details_url` populated.
50
+ Errors with `cdb/not-found`. `details_url` populated. The remediation names
51
+ `centrs devices init --cdb-file <path>` (example 54).
50
52
 
51
53
  ## show
52
54
 
@@ -634,3 +636,32 @@ Both succeed: the `add` stores the freeform comment verbatim, and the later
634
636
  `set timeout=5000` does **not** raise `input/incomplete-gps` even though the
635
637
  record's GPS pair is incomplete. Touching `lat`/`lon` (e.g. `set lat=…`) would
636
638
  re-enable the pairing check.
639
+
640
+ ## init
641
+
642
+ `init` is the explicit way to start a CDB at a path you choose (#376). Reads
643
+ and mutations never create an explicit `--cdb-file`/`CENTRS_CDB_FILE`; only the
644
+ default path is created implicitly on first run.
645
+
646
+ ### 54. Start a repo-local CDB
647
+
648
+ ```bash
649
+ centrs devices init --cdb-file ./lab/winbox.cdb --json
650
+ centrs devices add 192.0.2.1 --user robot-reader --cdb-file ./lab/winbox.cdb --json
651
+ ```
652
+
653
+ Creates missing parent directories and an empty open CDB with mode `0600`.
654
+ `data` is `{ cdbFile, created: true, recordCount: 0 }`. The `add` then succeeds.
655
+
656
+ ### 55. `init` never clobbers
657
+
658
+ ```bash
659
+ centrs devices init --cdb-file ./lab/winbox.cdb --json
660
+ ```
661
+
662
+ On an existing, readable CDB: `ok: true`, `data.created: false`,
663
+ `data.recordCount` is the current count, and a `cdb/file-exists` warning says
664
+ nothing was written. As with `git init`, re-running is safe. An existing file that
665
+ does not load fails with that load error (`cdb/parse-failed`,
666
+ `cdb/password-required`) and is left untouched. A location that cannot be
667
+ written fails with `cdb/create-failed`.
@@ -1,7 +1,7 @@
1
1
  # execute
2
2
 
3
3
  Run a RouterOS CLI command and return its (semi-structured) output. `execute`
4
- is the single read/write surface for RouterOS add/set/remove and other
4
+ is the single read/write surface for RouterOS configuration and operational
5
5
  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
@@ -15,7 +15,8 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
15
15
 
16
16
  - Mirror RouterOS `execute` semantics. Input is a CLI string; output is
17
17
  console-shaped text (or structured records for path+verb writes) wrapped in
18
- the standard envelope. This includes write-shaped add/set/remove.
18
+ the standard envelope. This includes selector-, positional-id-, and
19
+ non-CRUD-shaped writes.
19
20
  - Validation runs **offline first, then on the device** (see constitution:
20
21
  validation; GH#354). Stage 1 is the corpus-gated analyzer over the exact
21
22
  command string, with no connection: a syntax fault is rejected with a byte
@@ -27,8 +28,10 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
27
28
  GH#230): a single `:put [:parse "<cmd>"]` covers both the syntax and the
28
29
  unknown-attribute (name-level) gate on **every** transport — `:parse` never
29
30
  throws; its return value's printed form (`(evl …)` / `(evl bad parameter …)` /
30
- `(<%% bad command name …)`) is read from the REST/native `ret` *and* the
31
- console output. That check is necessary, not sufficient: value-type errors pass
31
+ `(<%% bad command name …)`, or a bare line-anchored `syntax error` /
32
+ `expected …` / `missing … (line N column M)`, examples V4) is read from the
33
+ REST/native `ret` *and* the console output. An accepted return can echo
34
+ those words, so only the anchored forms reject. That check is necessary, not sufficient: value-type errors pass
32
35
  it, and the `/console/inspect request=child` / server re-validation semantic
33
36
  checks are separate. Command-level `request=completion` is deliberately not
34
37
  used: it hangs REST and closes native API on RouterOS 7.12.2 (GH#343), while
@@ -36,9 +39,21 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
36
39
  The outer RouterOS string literal escapes backslash, quote, and dollar so
37
40
  validation parses the caller's exact command rather than expanding variables
38
41
  in the wrapper.
39
- - centrs owns the **script-vs-structured gate** (`canonicalizeExecuteCommand` +
40
- `isWriteShaped` in `src/execute.ts`): it decides which validation runs and
41
- whether the write-confirm prompt fires. The shared `rosetta`/`lsp-routeros-ts`
42
+ - centrs owns the **script-vs-structured gate** (`canonicalizeExecuteCommand` in
43
+ `src/execute.ts`): it decides which validation and transport path runs.
44
+ `isWriteShaped` is deliberately independent: script fallback for `[find]`, a
45
+ positional id, or other non-`key=value` input never bypasses confirmation.
46
+ Known read verbs are allowlisted; an unknown menu verb fails closed as a
47
+ write-shaped operation. In script mode every command head in a statement
48
+ (the statement start and each `[…]` or `{…}` body) must be a read: its verb
49
+ is the first word past the known menu prefix, so `/ip address print` is a
50
+ read and `/user expire-password print` is not (examples W3). Root mutators such as `/import` and indirect
51
+ execution through `:execute` are write-shaped too, and so is any command
52
+ carrying `file=`: RouterOS redirects command output into a device file, so
53
+ `/ip/address/export file=address` creates `address.rsc` and
54
+ `/file/print file=test` creates `test.txt` even though `export` and `print`
55
+ are read verbs. The shared
56
+ `rosetta`/`lsp-routeros-ts`
42
57
  canonicalizer is for canonicalization only — never the structured predicate
43
58
  (widening `structured` is a product regression). Pinned by
44
59
  `test/unit/execute-canonicalize-contract.test.ts`; the parser-vendoring
@@ -68,12 +83,11 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
68
83
  else passes its `detail`/body plus the HTTP status through
69
84
  `mapRouterOsError({ transport: "rest-api", httpStatus })`. There is no longer
70
85
  a hand-rolled `routeros/*` table on the REST side.
71
- - **Run-time fault mapping — later (JG-28).** `mapRouterOsError`'s table covers
72
- the faults seen so far; a known gap is errors that *pass* `:parse`/validation
73
- but fail at **run time** (a syntactically valid command the router rejects when
74
- executed). `tikoci/vscode-tikbook` has an error-string mapping for these worth
75
- porting into `src/core/routeros-errors.ts`. Background research, not yet
76
- scheduled — captured here so it isn't lost (no GitHub issue by design).
86
+ - **Run-time fault mapping.** A successful transport can still carry a RouterOS
87
+ rejection in `/execute`'s `ret`. Result detection lives beside the grounded
88
+ mappings in `src/core/routeros-errors.ts`, uses anchored result shapes so
89
+ ordinary output containing error-like prose remains output, and preserves a
90
+ passed validation history while failing the overall envelope.
77
91
  - Output is *string*-shaped for script-mode; richer parsing is a future
78
92
  concern. The envelope must still distinguish RouterOS errors from successful
79
93
  runs (a 200 with a RouterOS error string is still an error — see
@@ -216,8 +230,8 @@ many tokens):
216
230
  it fans out; mixing it with positionals or CDB selectors is
217
231
  `usage/conflicting-flags` (constitution: resolution providers).
218
232
 
219
- Because `execute` is the primary **write** path, a write-shaped fan-out
220
- (add/set/remove) is gated by **`--yes`**, confirmed **once** up front; without it
233
+ Because `execute` is the primary **write** path, a write-shaped fan-out is gated
234
+ by **`--yes`**, confirmed **once** up front; without it
221
235
  the error names the blast radius (how many routers). Write-ness is determined from
222
236
  the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
223
237
  fires before any target is dialed. Per-target validation still runs because
@@ -428,6 +428,95 @@ and `meta.validation.stages` listing **both** stages as `skipped`. A gated
428
428
  surface always reports every stage, so a consumer reading `stages` never has to
429
429
  special-case the disabled shape.
430
430
 
431
+ ### V4. A device-only `missing …` rejection is still a rejection
432
+
433
+ The offline stage passes `/ip/address/find where=`: an empty `where` is not a
434
+ structural fault it can see. RouterOS's `:parse` returns
435
+ `missing value for where (line 1 column 24)` (CHR 7.24.4), a bare diagnostic
436
+ like `syntax error`, so the device stage must reject it (GH#375). Before that
437
+ fix the classifier only knew `syntax error`, `bad command name` and
438
+ `expected …`, so it reported this reply as a pass and the command ran.
439
+
440
+ ```bash
441
+ centrs execute $R '/ip/address/find where=' --via rest-api --username $U --password $P --json
442
+ ```
443
+
444
+ Envelope: `ok: false`, `error.code=validation/syntax`, `error.context.detail`
445
+ is RouterOS's own `missing value for where (line 1 column 24)`,
446
+ `error.position={"line":1,"column":24}`, and `meta.validation.stages` is
447
+ `offline: passed` followed by `device: failed`.
448
+
449
+ CHR 7.24.4 controls, via `:put [:parse "…"]`: `find where` without `=` parses
450
+ (IL). `print where=` is rejected differently (`expected end of command`, at
451
+ `where`). An unclosed bare `{` block or menu block (`/ip address {` then
452
+ newlines) returns `missing closing brace (line 3 column 1)`, while an unclosed
453
+ `do={` returns `expected closing brace`. Offline analysis usually catches the
454
+ unclosed-brace cases first.
455
+
456
+ ## Write authorization and returned runtime faults
457
+
458
+ These regressions are transport-independent and run in
459
+ `test/integration/execute.test.ts` (with MCP's selector form also covered by
460
+ `test/integration/mcp.test.ts`).
461
+
462
+ ### W1. Script-mode selector and positional writes still require confirmation
463
+
464
+ ```bash
465
+ centrs execute $R '/ip/address/remove [find comment=centrs-write-gate]' --via rest-api --username $U --password $P </dev/null
466
+ ```
467
+
468
+ The same contract holds for `/ip/address/remove *1`. Both stay script-mode so
469
+ RouterOS receives the raw selector/id syntax, but the independent safety gate
470
+ returns `usage/confirmation-required` before validation or mutation.
471
+
472
+ ### W2. Non-CRUD mutation verbs still require confirmation
473
+
474
+ ```bash
475
+ centrs execute $R '/ip/address/disable numbers=*1' --via rest-api --username $U --password $P </dev/null
476
+ ```
477
+
478
+ Envelope: `ok: false`, `error.code=usage/confirmation-required`; the address
479
+ remains enabled. Unknown RouterOS menu verbs also fail closed as write-shaped.
480
+ Root mutators such as `/import` and indirect execution through `:execute` use
481
+ the same gate.
482
+
483
+ ### W3. Space-separated menu reads run without confirmation
484
+
485
+ ```bash
486
+ centrs execute $R '/system identity print' --via rest-api --username $U --password $P </dev/null
487
+ ```
488
+
489
+ Envelope: `ok: true`. The space-separated spelling puts menu segments and the
490
+ verb in bare words, so the gate walks the known menu prefix and reads the first
491
+ word past it as the verb. `/ip address print where …` and
492
+ `:put [/system identity get name]` are reads the same way. The walk is also what
493
+ keeps an unlearned command gated: `/user expire-password print` names
494
+ `expire-password`, a published command outside both verb allowlists, whose
495
+ argument happens to be `print`, so it returns `usage/confirmation-required`.
496
+ Every command head in a statement is checked, including `[…]` and `do={…}`
497
+ bodies.
498
+
499
+ ### R1. A post-validation `/execute` rejection fails the overall envelope
500
+
501
+ ```bash
502
+ centrs execute $R '/ip/service/set www-ssl certificate=nope' --via rest-api --username $U --password $P --yes --json
503
+ ```
504
+
505
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`, exit `1`, while
506
+ `meta.validation.result="passed"` and both stages remain `passed`: the rejection
507
+ happened at run time, after the scoped checks completed. The same result is
508
+ asserted over native API.
509
+
510
+ ### R2. Error-like prose remains ordinary script output
511
+
512
+ ```bash
513
+ centrs execute $R ':put "status: no such item appears in ordinary output"' --via rest-api --username $U --password $P --json
514
+ ```
515
+
516
+ Envelope: `ok: true`. Returned-text classification uses anchored grounded
517
+ shapes; it does not fail arbitrary output merely because it contains an error
518
+ phrase.
519
+
431
520
  ## Target selection (fan-out)
432
521
 
433
522
  These exercise the shared target-selection grammar
@@ -172,7 +172,7 @@ today's path/verb/args split and script-vs-structured gate:
172
172
  The *container* half of that pair is since decidable. `src/explain/menus.ts`
173
173
  (#207) bakes only the menu-vs-command node type — not a schema — and it is
174
174
  version-less because the pinned trees show no `dir`↔`cmd` flip across
175
- 7.10.2 → 7.25beta3 or across architectures. So `/ip/route` now resolves as
175
+ 7.10.2 → 7.25beta5 or across architectures. So `/ip/route` now resolves as
176
176
  `navigation` (#210). The floor is unmoved: the table is deliberately
177
177
  incomplete, absence from it decides nothing, and every rule above it still
178
178
  refuses rather than guesses.
@@ -221,6 +221,17 @@ today's path/verb/args split and script-vs-structured gate:
221
221
  coherent generated catalog has 1,120 paths: 959 `both`, 67 `inspect`, and 94
222
222
  `published`; it still reports zero navigation-vs-command contradictions.
223
223
 
224
+ **Source refresh (accepted 2026-09-27).** The two current pins advance to the
225
+ channels then live — stable 7.24.2 → 7.24.4 and development 7.25beta3 →
226
+ 7.25beta5 — both still architecture-paired, both history anchors untouched.
227
+ The refresh is a structural no-op: the inspect union holds at 617 menus and
228
+ the catalog at 1,120 paths, with no path added or removed and no kind change.
229
+ What moved is current-only publication: the CLI Reference grows from 1,053
230
+ pages / 1,060 entries to 1,055 / 1,062, which documents
231
+ `/interface/ethernet/pon` and `/ip/ssh/known-hosts` for the first time and so
232
+ moves both from `inspect` to `both` — 961 `both`, 65 `inspect`, 94
233
+ `published`. Zero navigation-vs-command contradictions, unchanged.
234
+
224
235
  There are real unknowns in how far offline parsing can go (expression
225
236
  grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
226
237
  those unknowns was **phase 0** (#185); its findings are folded in throughout
@@ -125,7 +125,8 @@ benchmarked RouterOS agent support. Load-bearing findings that shape this design
125
125
  - `centrs_retrieve` mirrors `retrieve`, including attribute projection,
126
126
  `listAttributes`, and `group` fanout.
127
127
  - `centrs_execute` mirrors `execute`. Read-shaped commands run subject only to
128
- CDB membership; write-shaped add/set/remove are gated by the CDB policy below.
128
+ CDB membership; every write-shaped command is gated by the CDB policy below,
129
+ including selector/positional script forms and non-CRUD mutation verbs.
129
130
  Its MCP `destructiveHint` is **conservative — always `true`**, even though the
130
131
  tool also serves read-shaped commands: a single execute tool cannot vary the
131
132
  static annotation by argument, so it advertises the worst case and lets the
@@ -84,6 +84,18 @@ Rejected before any network call, with `usage/conflicting-flags`.
84
84
  centrs retrieve $R /system/resource --attribute uptime --all-attributes --username $U --password $P --json
85
85
  ```
86
86
 
87
+ ### 20. Comma-list --attributes on any singleton
88
+
89
+ Singletons are recognized from `/console/inspect` — their `get` takes no
90
+ `number` argument — so every singleton validates against its `get value-name`
91
+ properties, not against `print`'s flags (#377).
92
+
93
+ ```bash
94
+ centrs retrieve $R /tool/romon --attributes enabled,id --username $U --password $P --json
95
+ ```
96
+
97
+ Envelope: `data` is an object containing only `enabled` and `id`.
98
+
87
99
  ## --list-attributes
88
100
 
89
101
  ### 9. List attributes for a path
@@ -336,6 +348,14 @@ centrs retrieve $A /system/resource --attribute bogus-attr --via native-api --po
336
348
  centrs retrieve $A /system/resource --via native-api --port $API_PORT --username wrong --password wrong
337
349
  ```
338
350
 
351
+ ### N12. Singleton `--attributes` projection (#377)
352
+
353
+ ```bash
354
+ centrs retrieve $A /tool/romon --attributes enabled,id --via native-api --port $API_PORT --username $U --password $P
355
+ ```
356
+
357
+ `data` is an object containing only `enabled` and `id`.
358
+
339
359
  ## quickchr targets (#134)
340
360
 
341
361
  `$NAME` is the machine name of a running quickchr-managed CHR
package/docs/CLI.md CHANGED
@@ -234,7 +234,7 @@ Usage: centrs explain '<input>' [flags]
234
234
  Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.
235
235
 
236
236
  ```text
237
- Usage: centrs devices <list|show|groups|add|edit|set|remove> [args] [flags]
237
+ Usage: centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]
238
238
  ```
239
239
 
240
240
  | Flag | Value | Description |