@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
package/CHANGELOG.md CHANGED
@@ -6,46 +6,204 @@ 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
+ Each stable version has one section. Work in progress goes under
10
+ `## X.Y.Z — next`, which is what `@next` installs (`X.Y.Z-next.N`); the stable
11
+ release dates that heading. A `-next.N` build has no section of its own.
10
12
 
11
- ## 0.1.8 — 2026-10-12
13
+ ## 0.1.9 — next
12
14
 
13
- Release (npm tag `latest`; `next` moves forward to 0.1.8). Everything in
14
- 0.1.8-next.0 below, plus two fixes from field-testing it.
15
+ In progress on npm tag `next`. The `0.1.9-next.0` cut (2026-10-13) carried
16
+ part of this section.
15
17
 
16
- ### Fixed
18
+ ### Added
17
19
 
18
- - `retrieve --wait` with no deadline value names `--wait` (#445). Written as a
19
- bare switch, `--wait --until 'expr'` read `--until` as the deadline and
20
- shifted the predicate into a second target, so the CLI reported
21
- `usage/fanout-not-supported`. It now fails `settings/invalid-timeout`
22
- ("Received the flag: --until"), and every deadline without a unit gets the
23
- `--wait`-specific message. A real multi-target `--wait` still gets the
24
- fanout refusal.
25
- - MCP accepts a registered device by its `identity=`, `mac=` or `ip=` comment
26
- key, as the CLI does (#444). The allowlist gate now matches with the same
27
- resolver the call dials with, so the `mcp=` write policy checked is the
28
- policy of the record the call runs against. A name on more than one record
29
- fails `identity/ambiguous` with the candidates in `context.matches` and an
30
- MCP remediation, instead of resolving to the first.
20
+ - `centrs --version` / `-V` prints the package version alone; `--json` (or
21
+ `--format json|yaml`) returns an envelope with `data.version` (#463).
22
+ - `execute --cmr` warns `cmr/caller-policy-not-enforced` when a write runs
23
+ through a controller login that lacks `write`, `test` or `reboot`. On
24
+ 7.26beta1 RouterOS runs the client script with all four anyway, so the
25
+ login is not the limit (#456). MikroTik says 7.26beta2 refuses this for a
26
+ `read` group. The warning is also in the confirmation
27
+ error. centrs reads the login's own `/user` row and group once, only for a
28
+ write; a failed read only loses the warning.
29
+ - The write gate of a CMR controller fleet command run with plain `execute` or
30
+ `api` names the devices it reaches (#453). `/cmr/device/reboot labels=all`
31
+ used to read as one router; centrs now reads the controller's `/cmr/device`
32
+ inventory first and says, for example, "2 CMR device(s) through controller
33
+ ctl, the controller itself among them" (`error.context.cmr`). The covered commands are
34
+ `/cmr/device/{reboot,upgrade,pair,run-script}`, `/cmr/upgrade/trigger` and
35
+ rule changes on `/cmr/vlan`, `/cmr/wifi`, `/cmr/wifi/radio` and
36
+ `/cmr/upgrade`. New warnings, with or without `--yes`:
37
+ `cmr/selection-includes-controller` and `cmr/rule-selects-every-device`
38
+ (a VLAN rule with no `labels` and no `port-labels` cuts the fleet's
39
+ management access). `cmr/empty-selection` is also a warning here.
40
+ `run-script` and `reboot` carry `cmr/caller-policy-not-enforced` as
41
+ `execute --cmr` does. The command still runs as typed, and a failed read
42
+ never blocks it.
43
+
44
+ - `execute <controller> --cmr --labels <expr> -- '<command>'` runs one
45
+ validated command on CMR clients through their controller (RouterOS
46
+ 7.26beta1+; #450, #451). Only the controller is dialed, with its own
47
+ credentials. Each selected client validates the command with `:parse`
48
+ first, and a client whose answer is incomplete or rejected is not sent it.
49
+ The command then goes to each client in its own `run-script` call, bound to
50
+ the `/cmr/device` row and key it was validated on, and is never retried; a
51
+ timeout, cancellation or lost call is `cmr/outcome-unknown`. One `--yes`
52
+ covers the whole selection. Native API (default) and REST to the controller
53
+ are both CHR-tested. Raw `run-script` through `api` or plain `execute` warns
54
+ `cmr/client-script-not-validated`. Not on MCP yet.
55
+ - `cmr/policy-denied`: a statement CMR scripts may not run (`/user add`,
56
+ `/tool sniffer`: the client allows only `read`, `write`, `test` and
57
+ `reboot`) is reported as such instead of a generic RouterOS failure, also
58
+ after earlier output. `:parse` accepts these commands, so validation cannot
59
+ catch them, and statements before the refused one have already run.
60
+ - `cmr/console-only`: `execute` and `api` refuse `/cmr/device/dashboard`. A
61
+ plain path is refused before dialing; any other spelling (a relative menu
62
+ change, a block, an `/execute` script) is found in the controller's `:parse`
63
+ return. On 7.26beta1 it closed a native API session, and over REST it held
64
+ the controller's REST service for every caller for 10–30 s.
65
+ - `execute --cmr` against a router with no `/cmr/device` says why. RouterOS
66
+ older than 7.26beta1 (CMR is beta-only) is `routeros/version-unsupported`
67
+ with `capability: cmr-controller`. A 7.26beta1+ router without the `cmr`
68
+ package stays `cmr/not-a-controller`, now naming its version and, for a CMR
69
+ client, its controller.
70
+ - `execute --cmr` accepts a command that opens with a relative menu change
71
+ (`/system; identity/print`); its `:parse` return was read as incomplete.
72
+
73
+ ### Changed
74
+
75
+ - Text stdout is data (#470). In text mode, single-target `execute`, `api`,
76
+ `retrieve` and `transfer` write only the result to stdout. Everything else
77
+ goes to **stderr** on success and on failure: the `--verbose` header, one
78
+ `warning [<code>]: <message>` line per warning, and the `Tips:` footer.
79
+ Plain `execute` and `api` used to drop warnings and envelope tips, so a
80
+ `--yes` write never showed, for example, `cmr/rule-selects-every-device` or
81
+ `transport/write-outcome-unknown`. `retrieve` and `transfer` used to mix
82
+ them into stdout. `transport/provider-trust` stays out of text output.
83
+ `--json` keeps everything, `api --raw` is unchanged, and fan-out text still
84
+ lists warnings in its report.
85
+ - **Breaking:** shared meta is written once (#461, #462). In a fan-out
86
+ envelope, each target's `meta.settings` keeps only the keys whose source differs from the
87
+ outer `meta.settings`. Its `meta.operation` drops `request` and `auth` when
88
+ they equal the outer operation's. When every target agrees on `auth`, or on
89
+ a setting the outer `meta.settings` lacks (`api` and `transfer` had none), it
90
+ moves to the outer envelope. An `execute --cmr` member no longer
91
+ repeats the controller in `meta.target.cmr`, because the outer `meta.target`
92
+ is the controller. A 3-client `execute --cmr` result went from 9.8 KB to
93
+ 5.4 KB. `expandFanoutTarget()` returns a target's full meta. A
94
+ `retrieve --follow` frame now carries only `meta.operation`;
95
+ `started`, `synced` and the summary still carry the full meta. A frame line
96
+ went from about 1.15 KB to 270 bytes.
97
+ - **Breaking:** all three streams (`retrieve --follow`, `retrieve --sample`,
98
+ `api --stream`) open with a full-meta `started` line (`data: null`), replacing
99
+ `notice` where it was emitted (#473, #480). Advice is in its `tips`. Only
100
+ `meta.operation` remains on each `frame` or `sample`; the full meta stays
101
+ on `started`, `summary`, and follow's `synced`. Consumers must handle
102
+ `started` and read shared meta from those lines. `apiStream()` now yields
103
+ `ApiStreamLine`, and `retrieveSample()` yields `RetrieveStreamLine`;
104
+ `ApiStreamFrameEnvelope`, `StreamLineEnvelope` and `isStreamDataLine` are
105
+ exported for consumers. API frames also gain `observedAt` (host receipt
106
+ time, ISO 8601). In text mode only frame/sample rows go to stdout; status
107
+ lines, tips, warnings and errors go to stderr. Structured formats keep
108
+ every line on stdout, and `api --raw` omits `started`.
109
+ - `retrieve --follow` frames carry `observedAt` (ISO 8601, host clock): when
110
+ centrs received the reply the frame is built from (#462).
111
+ - With `--query`, a removal the follow's sweep finds is `source: "sweep"`,
112
+ as documented; it was labelled `membership` (#462).
113
+ - An empty selection is an error for a write: `execute`, mutating `api` and
114
+ `transfer upload|remove|mkdir|copy` fan-outs (`cdb/empty-group`,
115
+ `cdb/empty-selection`) and `execute --cmr` (`cmr/empty-selection`) now exit 1
116
+ with `ok: false`, with or without `--yes`. A mistyped group or label passed
117
+ for a successful bulk write with exit 0 (#460). A read over an empty
118
+ selection is unchanged: `ok: true` with the warning.
119
+ - The write gate treats pure script functions as reads, so a structured read
120
+ such as `:put [:serialize to=json [/system/resource/print as-value]]` no
121
+ longer needs `--yes` (#458). The added directives are `:serialize`,
122
+ `:deserialize`, `:convert`, `:delay`, `:find`, `:timestamp`, `:jobname`,
123
+ `:nothing`, `:range`, `:rndnum`, `:rndstr`, `:retry`, `:time` and the `:to*`
124
+ converters, all from the grounded root-command vocabulary. Commands inside
125
+ their arguments and `{…}` bodies are still checked; on CHR 7.24.5 and
126
+ 7.26beta1, RouterOS refuses a quoted string as a script body. `file-name=`
127
+ (`:serialize`'s output file) now gates like `file=`. `api POST
128
+ /console/inspect` is a read.
129
+ - CMR fleet commands (`/cmr/device/{run-script,reboot,upgrade,pair}`,
130
+ `/cmr/upgrade/trigger`, `/cmr/upgrade/job/run-next`) are pinned as writes in
131
+ every spelling, over `execute`, `api` and MCP `mcp=ro`. On 7.26beta1 a
132
+ controller login with only `read` + `api` can run them (#456).
133
+ - CI, release, Pages and the Docker image run Bun 1.4.2 (was 1.3.14), the
134
+ version local development already used; `@types/bun` moves with it (#438).
135
+ `engines.bun` stays `>=1.3.11 <2`. The `typescript` peer stays `^6.0.3`:
136
+ the source passes `tsc --noEmit` under TypeScript 7.0.2, but typedoc 0.28 (the
137
+ API reference build) supports TypeScript up to 6.0.x only.
138
+ - QA records a CHR leg in `qa-history` only when its test step passed or
139
+ failed. A leg cancelled by a newer push, or skipped after a setup failure,
140
+ was recorded as `fail` (#442).
141
+ - CHR integration runs prefetch their selected image before tests start,
142
+ including direct file runs, so a cold-cache download does not consume a
143
+ test timeout. `bun run chr:prefetch` is the explicit entry point (#468).
144
+ - A centrs flag written after `--` is refused before anything dials, as
145
+ `usage/flag-after-separator`, with the corrected command line (#459). This
146
+ applies to `execute` (including `--cmr`) and `explain`. Before, a trailing
147
+ `--json` was sent to RouterOS and failed there as `unknown-attribute`. Only
148
+ a command's own long flags are matched (short aliases such as `-u` stay
149
+ RouterOS input), and password values are not echoed.
31
150
 
32
- ## 0.1.8-next.0 — 2026-10-12
151
+ ### Fixed
33
152
 
34
- Pre-release (npm tag `next`; `latest` stays on 0.1.7). Install with
35
- `bunx @tikoci/centrs@next`.
153
+ - Abbreviated output-file arguments (`fi=`, `file-n=`), and the spaced
154
+ `file-name value` form under print/export/serialize, require write
155
+ confirmation in `execute`, including inside scripts, and are refused for
156
+ MCP `mcp=ro` records. RouterOS accepted these spellings while centrs
157
+ recognized only `file=` and `file-name=`, letting file creation bypass
158
+ both gates.
159
+ - The suggested command in `usage/flag-after-separator` redacts credentials
160
+ in positional targets and `--host` values, including a host flag moved
161
+ before `--` and percent-encoded URL credentials. Inline value flags keep their
162
+ flag names in the correction. This closes error-path gaps in the target
163
+ redaction below; explicit URL credentials are refused before CDB lookup too.
164
+ - Serialized error causes keep `name`, `message` and `code` and omit raw JS
165
+ stack traces and their local installation paths (#476, #480).
166
+ - A target with credentials in it (`http://admin:pass@host`, `admin:pass@host`,
167
+ `admin@host`) is refused with `input/invalid-target`, which names
168
+ `--username` and `--password` (#478). centrs never used those credentials,
169
+ and it echoed the whole string, password included, in `meta.target.input`,
170
+ the fan-out selection and the `--verbose` header. The refusal covers the
171
+ positional, `--host`, `CENTRS_HOST`, CDB targets and fan-out literals, and
172
+ any displayed target has `user:pass@` replaced by `***@`, even when the
173
+ password holds `/`, `?` or `#`. A CDB identity containing `@` (`rtr@site`)
174
+ still resolves.
175
+
176
+ - `execute` and `api` never replay a write that was sent (#448). Fan-out
177
+ retried every `transport/network` and `transport/connection-closed` failure
178
+ up to twice, so a write whose connection dropped after the router had run
179
+ it could be applied up to three times. A write is now retried only when one
180
+ of those failures came before it was sent, for example during validation or
181
+ a native API login. A write that failed after it was sent, in single-target
182
+ or fan-out mode, carries the new warning `transport/write-outcome-unknown`,
183
+ because it may have run. Reads keep the retry.
36
184
 
37
185
  ### Maintenance
38
186
 
39
- - Refresh vulnerable dependencies within existing ranges and use Bun's
40
- Dependabot ecosystem for `bun.lock` (#390). Preserve the dependency group
41
- and the `has-flag` major-version ignore. Remaining dependency advisories
42
- and updater verification are tracked in #390.
43
- - Pre-release dependency alignment (#390): runtime ranges now floor at the
44
- tested versions (`@modelcontextprotocol/sdk ^1.32.1`, `zod ^4.6.7`),
45
- `smol-toml` is overridden past its advisories, and `@types/bun` is pinned
46
- exactly to the 1.3.14 runtime (re-pin tracked in #438). The two residual
47
- advisories (`braces`, `katex`) are dev-tool-only and do not ship in the
48
- package.
187
+ - Releases are one dispatch from `main` (#484, #485). `@next` publishes
188
+ `X.Y.Z-next.<run_number>` with no commit, PR or tag, and reuses main's QA
189
+ run on the commit instead of sweeping every channel again. `@latest` creates
190
+ the tag and a GitHub Release. A pushed `v*` tag no longer publishes. The
191
+ changelog keeps one section per stable version; the `0.1.8-next.0` and
192
+ `0.1.9-next.0` sections are folded into theirs.
193
+ - The CodeQL workflow no longer runs the AI-findings probe job (#486). Code
194
+ Quality AI findings are a paid feature this repository does not enable.
195
+ - Workflows reference actions by major tag, never by commit SHA, and
196
+ `SECURITY.md` records why (#266). Every `setup-bun` step reads the Bun
197
+ version from `packageManager`, so changing Bun is a one-line edit. Every
198
+ checkout except the QA history push drops its git credentials.
199
+ - zizmor audits the workflows: in CI as `Analyze (zizmor)` in `codeql.yaml`
200
+ (Security tab), and locally with `bun run lint:actions` (#266). Its first
201
+ run found two `${{ … }}` expansions inside shell scripts, in `lab.yaml` and
202
+ `qa.yaml`; both now go through `env:`.
203
+
204
+ ## 0.1.8 — 2026-10-12
205
+
206
+ Most of this section first shipped as `0.1.8-next.0` (2026-10-12).
49
207
 
50
208
  ### Added
51
209
 
@@ -83,6 +241,20 @@ Pre-release (npm tag `next`; `latest` stays on 0.1.7). Install with
83
241
 
84
242
  ### Fixed
85
243
 
244
+ - `retrieve --wait` with no deadline value names `--wait` (#445). Written as a
245
+ bare switch, `--wait --until 'expr'` read `--until` as the deadline and
246
+ shifted the predicate into a second target, so the CLI reported
247
+ `usage/fanout-not-supported`. It now fails `settings/invalid-timeout`
248
+ ("Received the flag: --until"), and every deadline without a unit gets the
249
+ `--wait`-specific message. A real multi-target `--wait` still gets the
250
+ fanout refusal.
251
+ - MCP accepts a registered device by its `identity=`, `mac=` or `ip=` comment
252
+ key, as the CLI does (#444). The allowlist gate now matches with the same
253
+ resolver the call dials with, so the `mcp=` write policy checked is the
254
+ policy of the record the call runs against. A name on more than one record
255
+ fails `identity/ambiguous` with the candidates in `context.matches` and an
256
+ MCP remediation, instead of resolving to the first.
257
+
86
258
  - `execute` honors an explicit target URL, and native-api honors a typed port
87
259
  (#426, from #384). With no `via` pinned, an `http(s)://host[:port]` target
88
260
  selects rest-api on that scheme and port instead of silently dialing native
@@ -262,6 +434,19 @@ Pre-release (npm tag `next`; `latest` stays on 0.1.7). Install with
262
434
  unchanged apart from those remediation strings and the restored
263
435
  `context.detail`.
264
436
 
437
+ ### Maintenance
438
+
439
+ - Refresh vulnerable dependencies within existing ranges and use Bun's
440
+ Dependabot ecosystem for `bun.lock` (#390). Preserve the dependency group
441
+ and the `has-flag` major-version ignore. Remaining dependency advisories
442
+ and updater verification are tracked in #390.
443
+ - Pre-release dependency alignment (#390): runtime ranges now floor at the
444
+ tested versions (`@modelcontextprotocol/sdk ^1.32.1`, `zod ^4.6.7`),
445
+ `smol-toml` is overridden past its advisories, and `@types/bun` is pinned
446
+ exactly to the 1.3.14 runtime (re-pin tracked in #438). The two residual
447
+ advisories (`braces`, `katex`) are dev-tool-only and do not ship in the
448
+ package.
449
+
265
450
  ## 0.1.7 — 2026-09-27
266
451
 
267
452
  ### Added
package/GLOSSARY.txt CHANGED
@@ -487,6 +487,14 @@ iff
487
487
  smol
488
488
  katex
489
489
 
490
+ # CI tooling vocabulary
491
+ # %zizmor: GitHub Actions static analyzer; config .github/zizmor.yml (#266).
492
+ # https://docs.zizmor.sh
493
+ # artipacked: zizmor audit for git credentials persisted by checkout.
494
+ zizmor
495
+ zizmorcore
496
+ artipacked
497
+
490
498
  # GitHub Pages build vocabulary (scripts/build-pages-site.ts, pages.yaml)
491
499
  # typedoc/pandoc: the API-reference generator and the markdown->HTML converter
492
500
  # the Pages build chains together. gfm: GitHub-Flavored Markdown, pandoc's
package/README.md CHANGED
@@ -11,13 +11,15 @@ 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. 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.
14
+ Use `bunx @tikoci/centrs@next --help` to inspect the full preview CLI, and
15
+ `bunx @tikoci/centrs@next --version` (`-V`; `--json` for an envelope) to print
16
+ the exact version for scripts and field reports. The dist-tag follows standard
17
+ SemVer semantics: a plain version (`0.1.8`) publishes to `latest` and carries
18
+ `next` forward with it, while a `next` build (`0.1.9-next.23`) publishes to
19
+ `next` only and never moves `latest`. A `next` build is described by the
20
+ `## 0.1.9 — next` section of [`CHANGELOG.md`](CHANGELOG.md). So `@next` is the
21
+ leading edge and can equal `@latest` between cuts —
22
+ `npm view @tikoci/centrs dist-tags` is the authority.
21
23
 
22
24
  The full flag reference for every command is generated from the CLI metadata
23
25
  into [`docs/CLI.md`](docs/CLI.md); per-command behavior lives in
@@ -149,6 +151,10 @@ so you can always see *why* a given port or transport was chosen.
149
151
  metadata) with a granular exit code (`0` all ok / `2` partial / `1` all failed);
150
152
  a multi-target write is gated once by `--yes` naming the blast radius. See
151
153
  `docs/CONSTITUTION.md` (Target selection grammar).
154
+ - **CMR fleets** (RouterOS 7.26beta1+) are selected on the controller instead:
155
+ `execute <controller> --cmr --labels <expr> -- '<command>'` validates the
156
+ command on each client through CMR and runs it there, using only the
157
+ controller's credentials. See `commands/execute/README.md` (CMR).
152
158
  - **Discovery** writes back into the CDB: `discover --save` records MNDP
153
159
  neighbors as new entries tagged `group=discovered` with a `source=mndp`
154
160
  marker — hints to curate, not authoritative inventory.
@@ -207,8 +213,10 @@ GitHub issues it links are the roadmap.
207
213
 
208
214
  Requirements:
209
215
 
210
- - Bun 1.3.11 or newer (CI pinned to 1.3.14; Bun 2.x not yet validated).
216
+ - Bun 1.3.11 or newer (CI runs the `packageManager` version in `package.json`;
217
+ Bun 2.x not yet validated).
211
218
  - Git.
219
+ - `uv`, only for `bun run lint:actions` (zizmor workflow audit).
212
220
  - For integration tests: QEMU plus `@tikoci/quickchr` (handles CHR image
213
221
  download and boot).
214
222
 
package/SECURITY.md CHANGED
@@ -14,14 +14,22 @@ Please include the affected files or workflow, reproduction details, and impact.
14
14
 
15
15
  The repository's [Security tab](https://github.com/tikoci/centrs/security) is the live source of current alerts and advisories. This section describes *what* checks run and *why*, so the doc stays meaningful even when the badge is at 0.
16
16
 
17
- - **CodeQL** — repo-managed workflow at [`.github/workflows/codeql.yaml`](.github/workflows/codeql.yaml) with config [`.github/codeql-config.yml`](.github/codeql-config.yml). Query suite: `security-and-quality` (security-extended + code-quality) plus a small custom pack, [`.github/codeql-house-rules/`](.github/codeql-house-rules/), that structurally checks a handful of repo-specific invariants (currently: `apiFanout` must call its named protocol/validation gates) — see `.github/instructions/github-security-quality.instructions.md` for scope and the verify-before-promote discipline for adding more rules there. Languages: `javascript-typescript`, `actions`. Schedule: push to `main`, pull requests to `main`, weekly cron, and manual dispatch.
18
- - **Code Quality (AI findings, preview)** — confirmed active in GitHub as of 2026-07-03 (not just "intended"; see [issue #166](https://github.com/tikoci/centrs/issues/166)). [`.github/workflows/codeql.yaml`](.github/workflows/codeql.yaml) includes a non-failing forward-compat probe for a future AI-findings API. AI findings are noisy and self-contradicting; we accept the noise because the second-opinion catches real issues that the static suite misses. Steady-state goal is 0 open findings. False positives are dismissed via the GitHub UI with a written justification — that text is the audit-log contract. Side effect: GitHub's own Code Quality scan runs in parallel with our Advanced Setup and posts a same-named `Analyze (javascript-typescript)` check, so PR checks lists show it twice — cosmetic, tracked in #166, not a required check either way (see issue #118).
17
+ - **CodeQL** — repo-managed workflow at [`.github/workflows/codeql.yaml`](.github/workflows/codeql.yaml) with config [`.github/codeql-config.yml`](.github/codeql-config.yml). Query suite: `security-and-quality` (security-extended + code-quality) plus a small custom pack, [`.github/codeql-house-rules/`](.github/codeql-house-rules/), that structurally checks a handful of repo-specific invariants (currently: `apiFanout` must call its named protocol/validation gates) — see `.github/instructions/github-security-quality.instructions.md` for scope and the verify-before-promote discipline for adding more rules there. Languages: `javascript-typescript`, `actions`. `test/` is excluded (`paths-ignore`); it is not shipped, and its CLI argv credentials over-tainted every CLI render path. Schedule: push to `main`, pull requests to `main`, weekly cron, and manual dispatch.
18
+ - **Code Quality** — GitHub's own scan, enabled in repository settings (not a workflow here). It runs as the separate "CodeQL - Code Quality" run on every PR and push and reports different findings from the CodeQL workflow above. Steady-state goal is 0 open findings. False positives are dismissed via the GitHub UI with a written justification — that text is the audit-log contract. Side effect: it posts a same-named `Analyze (javascript-typescript)` check, so PR checks lists show it twice — cosmetic (see [issue #166](https://github.com/tikoci/centrs/issues/166)), not a required check either way (see issue #118).
19
+ - **Code Quality AI findings** — not used: a paid feature, not enabled for this repository.
20
+ - **zizmor** — GitHub Actions static analysis, as the `Analyze (zizmor)` job in [`.github/workflows/codeql.yaml`](.github/workflows/codeql.yaml). Findings go to the Security tab and do not fail the job. [`.github/zizmor.yml`](.github/zizmor.yml) accepts tag refs (see GitHub Actions below) and turns off the Dependabot cooldown audit. `bun run lint:actions` runs it locally (needs `uv`); it is not part of `lint:ci`.
19
21
  - **Dependency review** — not enabled. GitHub dependency review follows dependency-graph ecosystem support, and this Bun-only repository currently does not have a supported lockfile for that gate.
20
22
  - **Dependabot security updates** — unsupported for Bun in GitHub's [ecosystem support table](https://docs.github.com/en/code-security/reference/supply-chain-security/supported-ecosystems-and-repositories). [`.github/dependabot.yaml`](.github/dependabot.yaml) configures weekly Bun **version updates** and GitHub Actions updates. Dependency security cleanup also requires `bun audit` and reviewed lockfile upgrades.
21
23
  - **Secret scanning** — enabled in GitHub, with push protection.
22
24
  - **Private vulnerability reporting** — enabled via GitHub Security Advisories.
23
25
 
24
- Some GitHub Security tab data, especially Code Quality AI findings, is not reachable through the default workflow `GITHUB_TOKEN` (`repos/{owner}/{repo}/code-quality/findings` 403s there even with `security-events: read`) — it does respond with an elevated/owner-scoped token, but no findings have been recorded yet to confirm the response shape end-to-end (see #166). Human review in the GitHub UI remains part of the security workflow until that's wired up.
26
+ Human review of the Security tab in the GitHub UI is part of the security workflow; no workflow gates on Code Quality findings.
27
+
28
+ ## GitHub Actions
29
+
30
+ - **Action refs are tags, not commit SHAs** (`actions/checkout@v7`, `oven-sh/setup-bun@v2`), for first-party and third-party actions alike. Use the major tag; an action that publishes none (`zizmorcore/zizmor-action`) gets its release tag. This is a decision ([#266](https://github.com/tikoci/centrs/issues/266)), not an oversight, so a scanner's unpinned-action finding is not a defect here: CodeQL's `actions/unpinned-tag` is excluded in [`.github/codeql-config.yml`](.github/codeql-config.yml), and zizmor's `unpinned-uses` accepts tags in [`.github/zizmor.yml`](.github/zizmor.yml). A tag picks up an action's fixes, including security fixes, on the next run. A SHA pin goes stale silently until a bot bumps it. We accept the risk of a moved tag or a compromised release in exchange. Dependabot's weekly `github-actions` updates move major and release tags.
31
+ - **The Bun version lives in one place.** Every `setup-bun` step reads `packageManager` from `package.json` (`bun-version-file`); the Dockerfile base image is the only other copy.
32
+ - **Checkouts drop git credentials** (`persist-credentials: false`) unless the job pushes. The only one that pushes is the QA accumulate job in `qa.yaml`, which commits to the `qa-history` branch.
25
33
 
26
34
  ## Supported versions
27
35
 
@@ -110,6 +110,11 @@ vs `--query` distinction.
110
110
  - **Writes fan out under `--yes`**, confirmed once up front (not per target).
111
111
  Without `--yes`, the error names the blast radius (how many routers) and that
112
112
  `--yes` is required.
113
+ - **A sent write is never retried** (#448). Reads, and writes that failed
114
+ before they were sent, keep the `transport/network` /
115
+ `transport/connection-closed` retry. A mutating request that failed after it
116
+ was sent carries the warning `transport/write-outcome-unknown`, in single-target
117
+ and fan-out mode alike.
113
118
  - **`--stream` (or a `/listen` endpoint) is single-session** → `usage/fanout-not-supported` in
114
119
  fan-out mode. **`--raw` strips the envelope** → `usage/conflicting-flags` in
115
120
  fan-out mode (per-target envelopes can't be bare).
@@ -165,14 +170,28 @@ the suspect.
165
170
  ## Write confirmation
166
171
 
167
172
  Read-only **iff** the method is `GET`, or the endpoint terminal verb is
168
- `print`/`get`, or it is `listen`. Everything else is treated as a write and needs
169
- confirmation (`--yes` non-interactively, or a TTY prompt) → otherwise
170
- `usage/confirmation-required`. Two consequences:
173
+ `print`/`get`, or it is `listen`, or it is a POST to exactly `/console/inspect`
174
+ (it describes and parses commands, never runs them; #458). Everything else is
175
+ treated as a write and needs confirmation (`--yes` non-interactively, or a TTY
176
+ prompt) → otherwise `usage/confirmation-required`. Consequences:
171
177
 
172
178
  - A `POST …/print` paged read does **not** prompt (keyed on the verb, not the
173
179
  wire HTTP method).
174
180
  - Streaming does **not** imply read-only: an async-but-mutating command like
175
181
  `/system/license/renew --stream` still confirms.
182
+ - A controller's CMR fleet commands (`/cmr/device/{run-script,reboot,upgrade,pair}`,
183
+ `/cmr/upgrade/trigger`, `/cmr/upgrade/job/run-next`) are writes whatever
184
+ RouterOS policy the login has: on 7.26beta1 `run-script` needs only `read` +
185
+ `api` on the controller (#456).
186
+ Before the gate, `api` reads the controller's `/cmr/device` inventory and
187
+ names the devices the request reaches, the controller's own row included,
188
+ with the same warnings as plain `execute`. Where the reach cannot be
189
+ resolved (for example `/cmr/upgrade/job/run-next`), the gate says so and
190
+ why (#453; see
191
+ [execute → Fleet commands run directly](../execute/README.md#fleet-commands-run-directly-453)).
192
+ `PUT`, `PATCH` and `DELETE` on `/cmr/vlan`, `/cmr/wifi`, `/cmr/wifi/radio`
193
+ and `/cmr/upgrade` are the menus' `add`, `set` and `remove`, and the `*id`
194
+ is the rule they act on.
176
195
 
177
196
  `--raw` does not bypass this gate.
178
197
 
@@ -204,7 +223,7 @@ centrs never substitutes polling.
204
223
  `ip/address/*1/listen` follows one row (`?.id=*1`).
205
224
  - **GET menu + `--stream`** streams one literal print and completes; it does
206
225
  not follow. Its summary carries `tip/stream-print` naming the `/listen`
207
- form (see the notice below). A streamed GET (or a `/listen`) rejects `-f` fields rather than
226
+ form (see `started` below). A streamed GET (or a `/listen`) rejects `-f` fields rather than
208
227
  silently dropping them.
209
228
  - **POST command + `--stream`** runs the literal command with its attributes:
210
229
  `api $R /tool/ping -X POST -f address=10.0.2.2 -f count=3 --stream --yes`.
@@ -214,14 +233,22 @@ centrs never substitutes polling.
214
233
  `usage/conflicting-flags` instead of being ignored. `/execute` retains
215
234
  synchronous `as-string` script semantics. Streaming never makes a command
216
235
  read-only.
217
- - Each wire reply emits one envelope with `meta.operation.stream.kind=frame`
218
- and `reply`: **`re`** (a row; `data` is the row, device `.section`,
236
+ - A stream that passed its preflight opens with one `started` line
237
+ (`meta.operation.stream.kind=started`, `data: null`). It carries the full
238
+ `meta` (target, settings, validation, request, auth) and any advice in
239
+ `tips`, and is not counted in `frames`/`rows`.
240
+ - Each wire reply emits one envelope with `meta.operation.stream.kind=frame`,
241
+ `observedAt` (when centrs received it, host clock, ISO 8601; a slow reader
242
+ does not move it) and `reply`: **`re`** (a row; `data` is the row, device `.section`,
219
243
  `.dead`, `.nextid` and `.about` verbatim) or **`empty`** (RouterOS `!empty`:
220
244
  zero rows; `data` is `null`). RouterOS sends `!empty` for an empty finite
221
245
  read, for **each zero-row tick** of `print interval=`, and for a listen
222
246
  cancelled before it sent anything. A frame received after centrs stopped the
223
247
  stream locally carries `afterStop: true` — that cancellation `!empty` is not
224
- evidence of an empty table. A terminal `!done` is not a frame: its
248
+ evidence of an empty table. A frame carries only `meta.operation` (`kind`,
249
+ `objectCount`, `stream`); the rest is on `started` and the summary
250
+ ([constitution](../../docs/CONSTITUTION.md#result-envelope)). A terminal
251
+ `!done` is not a frame: its
225
252
  attributes, when received, appear in successful summary `data.done`,
226
253
  including a terminal result received during local cancellation. The stop
227
254
  reason still reports the first local bound.
@@ -240,17 +267,19 @@ centrs never substitutes polling.
240
267
  one error envelope without starting a stream.
241
268
  - Structured streams put **all** envelopes, including errors, on stdout as
242
269
  NDJSON and exit nonzero for any failure, even after successful rows. Text
243
- mode uses the same outcome/exit rules. `--raw` retains bare payloads and
244
- stderr errors; it does not provide the structured stream contract.
270
+ mode uses the same exit rules but puts only frame rows on stdout; the
271
+ `started` tips, the summary, warnings and errors go to stderr. `--raw`
272
+ retains bare payloads and stderr errors; it does not provide the
273
+ structured stream contract.
245
274
 
246
275
  `api` never edits a change feed to make it safer; it says what RouterOS will
247
276
  not report as tips (CHR 7.23.7 + 7.24.5). Because that advice depends only on
248
- the request, it arrives **first**: a leading envelope with
249
- `meta.operation.stream.kind=notice`, `data: null` and the `tips`, before any
250
- reply — so a filtered watch that never gets a reply still shows it. A notice is
251
- not a reply and is not counted in `frames`/`rows`; a stream with no advice has
252
- no notice. The same tips are repeated on the terminal summary, successful or
253
- failed. `--raw` omits the notice.
277
+ the request, it arrives **first**, in the `tips` of the leading `started`
278
+ line, before any reply — so a filtered watch that never gets a reply still
279
+ shows it. The same tips are repeated on the terminal summary, successful or
280
+ failed. Advice from the live gate (such as `tip/rest-verb-mapping`) rides the
281
+ same two lines, as it does on a one-shot call. `--raw` omits the `started`
282
+ line.
254
283
 
255
284
  - `tip/filtered-follow` — a `listen` (or `follow`) with a query reports
256
285
  neither rows leaving the filter nor deletes. Follow unfiltered and filter
@@ -277,6 +277,18 @@ key. Contrast example 13, where bare `--raw` skips the preflight entirely. This
277
277
  pair is how you tell a RouterOS rejection from a centrs bug while staying on the
278
278
  bare-passthrough surface.
279
279
 
280
+ ### 23. POST `/console/inspect` is a read: no `--yes` needed (#458)
281
+
282
+ ```bash
283
+ centrs api $R console/inspect -X POST -f request=child -f path=system,identity --username $U --password $P </dev/null
284
+ ```
285
+
286
+ Envelope: `ok: true`, `data` lists the children of `/system/identity`
287
+ (including `print`). Inspect describes and parses commands but never runs them,
288
+ so a POST to exactly `console/inspect` skips the write gate, non-TTY and
289
+ without `--yes`. PUT/PATCH/DELETE to it (add/set/remove) and abbreviations
290
+ such as `con/insp` are still gated like example 15.
291
+
280
292
  ### H1. `--host` stands in for `<router>` (#428)
281
293
 
282
294
  ```bash
@@ -484,8 +496,8 @@ centrs api $A ip/address/listen --proplist address,.id,.dead --duration 3s --via
484
496
  ```
485
497
 
486
498
  The harness deletes a seeded address during each. With `address` alone no
487
- frame carries `.dead` for it, and the leading notice and the summary carry
488
- `tip/follow-proplist`.
499
+ frame carries `.dead` for it, and the leading `started` line and the summary
500
+ carry `tip/follow-proplist`.
489
501
  With `address,.id,.dead` the delete frame is `{ ".id", ".dead": "true" }` and
490
502
  there is no tip.
491
503
 
@@ -497,7 +509,7 @@ centrs api $A ip/address/listen --query interface=ether1 --duration 3s --via nat
497
509
 
498
510
  The harness deletes a seeded `ether1` address while listening. RouterOS sends
499
511
  nothing for it: the only frame is the cancellation `!empty` (`afterStop`).
500
- The first line is a `notice` with `tip/filtered-follow`; the summary is `ok`
512
+ The first line, `started`, carries `tip/filtered-follow`; the summary is `ok`
501
513
  and repeats it. `--raw-query` is sent
502
514
  the same way.
503
515
 
@@ -570,8 +582,8 @@ centrs api $A ip/address --stream --via native-api --port $API_PORT --username $
570
582
  ```
571
583
 
572
584
  The print completes on its own (`stopReason=completed`) with one `re` frame
573
- per row (`rows` equals a one-shot GET's row count); a leading notice and the
574
- summary carry `tip/stream-print` naming `ip/address/listen`. It never becomes a listen.
585
+ per row (`rows` equals a one-shot GET's row count); the leading `started` line
586
+ and the summary carry `tip/stream-print` naming `ip/address/listen`. It never becomes a listen.
575
587
 
576
588
  ### L18. Zero-row ticks are `!empty` frames and do not use up `--count`
577
589
 
@@ -621,7 +633,7 @@ centrs api --where board=chr ip/address --cdb-file $CDB --json
621
633
  The device-class selector matches only record 0, so `data.summary = { total: 1,
622
634
  ok: 1, failed: 0 }`, one inner success. Exit code 0.
623
635
 
624
- ### F3. An empty selection is `ok:true` with summary 0/0/0
636
+ ### F3. An empty selection is `ok:true` with summary 0/0/0 for a read
625
637
 
626
638
  ```bash
627
639
  centrs api --group no-such-group ip/address --cdb-file $CDB --json
@@ -630,6 +642,15 @@ centrs api --group no-such-group ip/address --cdb-file $CDB --json
630
642
  `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`, `data.targets = []`,
631
643
  and a `cdb/empty-group` warning. Exit code 0.
632
644
 
645
+ A mutating method over the same group is an outer error, with or without
646
+ `--yes`, so a misspelled group cannot pass for a successful bulk write (#460):
647
+
648
+ ```bash
649
+ centrs api -X PUT --group no-such-group ip/address -f address=10.99.0.9/32 -f interface=ether1 --yes --cdb-file $CDB --json
650
+ ```
651
+
652
+ `ok: false`, `error.code` `cdb/empty-group`. Exit code 1.
653
+
633
654
  ### F4. A mutating fan-out without `--yes` is rejected, naming the blast radius
634
655
 
635
656
  ```bash
@@ -691,6 +712,23 @@ target fails deterministically: inner `ok:false` with
691
712
  `error.code=target/unresolved` (never a `transport/dns` attempt on `"__default__"`).
692
713
  `data.summary = { total: 1, ok: 0, failed: 1 }`. Exit code 1 (every target failed).
693
714
 
715
+ ## CMR fleet commands (#453)
716
+
717
+ `test/integration/execute-cmr.test.ts` runs these on the CMR lab described in
718
+ [execute → CMR](../execute/examples.md#cmr-clients-through-a-controller-450),
719
+ over both `--via native-api` and `--via rest-api`.
720
+
721
+ ### CMR1. `POST /cmr/device/reboot` names the devices it reaches
722
+
723
+ ```bash
724
+ centrs api --quickchr $CTL -X POST /cmr/device/reboot -f labels=<client>
725
+ ```
726
+
727
+ Without `--yes` (no TTY) this is `usage/confirmation-required`. Its summary
728
+ names `1 CMR device(s)`, and `error.context.cmr` is
729
+ `{devices: 1, controllerIncluded: false, identities: ["<client>"]}`. Nothing
730
+ reboots.
731
+
694
732
  ## `--query` grammar (#397)
695
733
 
696
734
  `--query` takes the `print where` grammar shared with `retrieve --query`. The