@tikoci/centrs 0.1.9-next.0 → 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 (65) hide show
  1. package/CHANGELOG.md +177 -40
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +12 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +39 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +108 -14
  8. package/commands/execute/examples.md +118 -3
  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 +30 -13
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +8 -8
  16. package/docs/CONSTITUTION.md +25 -2
  17. package/docs/MATRIX.md +5 -4
  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/empty-selection.md +17 -3
  22. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  23. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  24. package/docs/errors/usage/flag-after-separator.md +34 -0
  25. package/package.json +7 -5
  26. package/src/api-fanout.ts +11 -1
  27. package/src/api.ts +205 -65
  28. package/src/btest.ts +7 -2
  29. package/src/cli/api.ts +30 -16
  30. package/src/cli/common.ts +180 -1
  31. package/src/cli/devices.ts +0 -2
  32. package/src/cli/discover.ts +0 -1
  33. package/src/cli/execute.ts +44 -20
  34. package/src/cli/explain.ts +2 -0
  35. package/src/cli/mcp.ts +0 -1
  36. package/src/cli/retrieve.ts +40 -18
  37. package/src/cli/settings.ts +0 -3
  38. package/src/cli/terminal.ts +0 -2
  39. package/src/cli/transfer.ts +10 -8
  40. package/src/cli/version.ts +87 -0
  41. package/src/cli.ts +5 -3
  42. package/src/core/cmr-fleet.ts +486 -0
  43. package/src/core/cmr.ts +78 -0
  44. package/src/core/envelope.ts +32 -3
  45. package/src/core/error-catalog.ts +25 -3
  46. package/src/core/execute-command.ts +144 -19
  47. package/src/core/fanout.ts +130 -6
  48. package/src/errors.ts +2 -1
  49. package/src/execute-cmr.ts +95 -78
  50. package/src/execute-fanout.ts +11 -1
  51. package/src/execute.ts +105 -23
  52. package/src/index.ts +18 -1
  53. package/src/mcp/safety.ts +11 -0
  54. package/src/mcp/tools.ts +4 -1
  55. package/src/resolver/cdb.ts +13 -1
  56. package/src/resolver/index.ts +4 -0
  57. package/src/resolver/selection.ts +37 -1
  58. package/src/resolver/target.ts +96 -8
  59. package/src/retrieve-fanout.ts +4 -1
  60. package/src/retrieve-follow.ts +29 -33
  61. package/src/retrieve-sample.ts +14 -8
  62. package/src/retrieve-stream.ts +38 -6
  63. package/src/retrieve.ts +73 -59
  64. package/src/transfer-fanout.ts +8 -1
  65. package/src/transfer.ts +15 -19
@@ -177,8 +177,29 @@ Notes:
177
177
  lossless rule applies to **stdout** only: **stderr** carries progress and
178
178
  interactive prompts (discover scan progress, write/ambiguity confirmation) and
179
179
  is exempt — use it sparingly, since most operations are fast.
180
+ - **Text stdout is data** (#470). In text mode a single-target `execute`,
181
+ `api`, `retrieve` or `transfer` writes only `data` (or its error) to stdout.
182
+ The notes around it go to stderr: the `--verbose` header first, then one
183
+ `warning [<code>]: <message>` line per warning, then the `Tips:` footer, on
184
+ success and on failure alike. `transport/provider-trust` is left out of text
185
+ because it rides every quickchr run over TLS or SSH. `--json` keeps
186
+ everything, `api --raw` stays a bare payload, and fan-out text is a report,
187
+ not data, so it keeps its warnings on stdout. A text stream writes only its
188
+ data rows (`frame`, `sample`) to stdout; its `started`, `synced` and
189
+ `summary` lines, tips, warnings and errors go to stderr (#473).
180
190
  - `data` is the only field whose *shape* varies by command. Everything else —
181
191
  including `warnings` and `tips` — is invariant in shape; only contents differ.
192
+ - **Shared meta is written once** (#461, #462). A fan-out target's
193
+ `meta.settings` keeps only the keys whose source differs from the outer
194
+ `meta.settings`, and its `meta.operation` drops `request` and `auth` when
195
+ they equal the outer operation's. When every target agrees on `auth`, or
196
+ on a setting the outer `meta.settings` lacks, it moves to the outer
197
+ envelope. A reader takes the target's value, else
198
+ the outer one (`expandFanoutTarget`). A stream (`retrieve --follow`,
199
+ `retrieve --sample`, `api --stream`) opens with a `started` line that
200
+ carries the full meta and any request advice in `tips`, and ends with a
201
+ full-meta `summary` (`--follow`'s `synced` is full too). The data lines
202
+ between (`frame`, `sample`) carry only `meta.operation` (#473).
182
203
 
183
204
  ## Error model
184
205
 
@@ -304,8 +325,10 @@ Fan-out output is the locked **`FanoutData`** envelope: `data = { summary, targe
304
325
  is an inner `ok:false` envelope (output, not metadata). `targets[]` is reassembled
305
326
  in resolved record order, not completion order, so repeated runs diff cleanly. The
306
327
  **process exit code** is granular and uniform across fan-out commands: `0` every
307
- target ok (or an empty selection), `2` partial (some ok, some failed), `1`
308
- orchestration error or every target failed.
328
+ target ok (or an empty selection for a read), `2` partial (some ok, some
329
+ failed), `1` orchestration error or every target failed. An empty selection is
330
+ a warning for a read and an orchestration error for a write, `--yes` or not,
331
+ so a mistyped selector never passes for a successful bulk write (#460).
309
332
 
310
333
  A multi-target **RouterOS write** (e.g. `execute` / `api` add/set/remove across a
311
334
  selection) is gated by **`--yes`**, confirmed **once** up front; when it is missing
package/docs/MATRIX.md CHANGED
@@ -187,10 +187,11 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
187
187
  (#450), which runs one validated command on CMR clients through their
188
188
  controller. The contract is in
189
189
  [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr),
190
- with examples CMR0–CMR14. The CMR-vs-centrs feature map is on #441
191
- (2026-10-13). Next, in order: the fleet blast-radius gate for controller fleet
192
- commands (#453), then #449's read-only slice (a `devices` view of members and a
193
- default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
190
+ with examples CMR0–CMR15. Also done: the fleet blast-radius gate for
191
+ controller fleet commands run with plain `execute` or `api` (#453), with
192
+ examples CMR16–CMR18 and api CMR1. The feature map of CMR against centrs is
193
+ on #441 (2026-10-13). Next is the read-only slice of #449 (a `devices` view
194
+ of members and a default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
194
195
  Live `explain` resumes after this.
195
196
 
196
197
  **The native-api / NDJSON line** (maintainer decision on
@@ -2,8 +2,24 @@
2
2
 
3
3
  The named CDB group contains no records.
4
4
 
5
+ ## Typical trigger
6
+
7
+ A fan-out selected only by `--group` (no other selector) whose group matched
8
+ no record: a misspelled or case-mismatched group name, or a group whose last
9
+ member was removed.
10
+
11
+ - For a **read**, it is a warning on an `ok: true` fan-out envelope with
12
+ `summary { total: 0, ok: 0, failed: 0 }` and exit code 0.
13
+ - For a **write-shaped** fan-out (`execute` write, a mutating `api` method,
14
+ `transfer upload|remove|mkdir|copy`) it is an outer error (`ok: false`, exit
15
+ code 1), with or without `--yes`. `context.write` is `true`.
16
+
17
+ Any other empty selection (`--where`, `--all`, positionals, mixed) is
18
+ [`cdb/empty-selection`](empty-selection.md).
19
+
5
20
  ## Fix
6
21
 
7
- See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
8
- contract. This stub will be expanded with the typical trigger and remediation
9
- for `cdb/empty-group`.
22
+ Run `centrs devices groups` to see the groups that exist and their member
23
+ counts, then re-run with the right name. See
24
+ [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) (Target selection) for the
25
+ selector grammar.
@@ -5,9 +5,15 @@ The target selection matched no CDB records.
5
5
  ## Fix
6
6
 
7
7
  A fan-out selection involving `--where` / `--all` / `--default` / positionals (on
8
- its own or mixed with `--group`) resolved to zero targets. This is reported as a
9
- warning on an `ok: true` fan-out envelope with `summary { total: 0, ok: 0, failed:
10
- 0 }`, not a hard failure — there was simply nothing to run.
8
+ its own or mixed with `--group`) resolved to zero targets.
9
+
10
+ - For a **read**, it is a warning on an `ok: true` fan-out envelope with
11
+ `summary { total: 0, ok: 0, failed: 0 }` and exit code 0: there was simply
12
+ nothing to read.
13
+ - For a **write-shaped** fan-out (`execute` write, a mutating `api` method,
14
+ `transfer upload|remove|mkdir|copy`) it is an outer error (`ok: false`, exit
15
+ code 1), with or without `--yes`, so a mistyped selector does not look like
16
+ a successful bulk write. `context.write` is `true`.
11
17
 
12
18
  A **group-only** empty (bare `--group`, no other selector) is reported as
13
19
  [`cdb/empty-group`](empty-group.md) instead, naming the group that matched nothing;
@@ -0,0 +1,46 @@
1
+ # `cmr/caller-policy-not-enforced`
2
+
3
+ A warning on `execute --cmr`, and on `/cmr/device/run-script` or
4
+ `/cmr/device/reboot` run directly with `execute` or `api`: the controller login
5
+ lacks a policy that the command runs with on the clients, and RouterOS does
6
+ not check it. The login does not limit what a CMR command can change.
7
+
8
+ ## Typical trigger
9
+
10
+ A write through `--cmr` with a controller login whose group lacks `write`,
11
+ `test` or `reboot`, such as a "reader" account given to an agent. CMR runs
12
+ every `run-script` script on each selected client with `read`, `write`,
13
+ `test` and `reboot`, the controller's own row included. On 7.26beta1,
14
+ RouterOS checked only that the caller had `read` and `api`. These results
15
+ come from the CHR reproduction on #456 (REST, two-CHR lab, 2026-10-13); the
16
+ same `run-script` write was seen on real hardware, and the integration test
17
+ re-proves it (CMR15 in `commands/execute/examples.md`):
18
+
19
+ | Controller login | Direct write or reboot | Through CMR |
20
+ | --- | --- | --- |
21
+ | `read,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to a client and rebooted it |
22
+ | `read,test,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to the controller's own row; `/cmr/device/reboot` rebooted a client |
23
+
24
+ MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) this is fixed for 7.26beta2: a `read` group will not
25
+ be allowed to do it. That has not been checked on a beta2 build yet. centrs
26
+ still warns on any version, because the warning reads the login's policy, not
27
+ the RouterOS version. On a build that refuses the write, the members fail
28
+ instead and the warning only says what the login lacks.
29
+
30
+ `context` names the login (`username`, `group`), the policies it lacks
31
+ (`missing`) and the ones the command runs with (`runsWith`): `read`, `write`,
32
+ `test` and `reboot` for `run-script`, and `reboot` for `/cmr/device/reboot`.
33
+ centrs reads the policy once per run, from the login's own `/user` row and
34
+ `/user/group`, and only for a write. When that read fails (for example a
35
+ RADIUS login with no `/user` row) there is no warning. Other fleet commands
36
+ (`/cmr/device/upgrade`, `pair`, the rule menus) were not measured, so they
37
+ carry no such warning.
38
+
39
+ ## Fix
40
+
41
+ Nothing failed; this is information. Do not treat a controller login as
42
+ read-only once the `cmr` package is installed (on 7.26beta1 at least). The
43
+ gates that hold are centrs' own: pass `--yes` only when you intend the write
44
+ on every selected client, and over MCP keep the controller's record at
45
+ `mcp=ro` (the default), which refuses CMR fleet commands outright. Give an
46
+ agent a login on the controller only if it may change every paired device.
@@ -1,8 +1,22 @@
1
1
  # `cmr/empty-selection`
2
2
 
3
- A warning: the `--labels` expression matched no device in the controller's
4
- `/cmr/device` inventory, so nothing ran. The result is an empty selection with
5
- exit code 0, the same as an empty CDB group.
3
+ The `--labels` expression of `execute --cmr` matched no device in the
4
+ controller's `/cmr/device` inventory, so nothing ran.
5
+
6
+ - For a **read**, it is a warning: `ok: true` with an empty selection and exit
7
+ code 0, the same as an empty CDB group. A collector that polls a sometimes
8
+ empty label stays quiet.
9
+ - For a **write-shaped** command it is an error (`ok: false`, exit code 1),
10
+ with or without `--yes`, so a misspelled label does not look like a
11
+ successful bulk write. RouterOS refuses an empty selection the same way
12
+ (`no devices selected`). `context.write` is `true`.
13
+
14
+ The same code is a **warning** on a CMR fleet command run directly with
15
+ `execute` or `api`, such as `/cmr/vlan/add labels=EDGE` or
16
+ `/cmr/device/reboot labels=Edge`: by the controller's inventory now, its
17
+ selection reaches no device. RouterOS refuses an empty selection for a
18
+ `/cmr/device` command itself; it accepts a rule whose labels match nothing,
19
+ and the rule applies to devices that get the label later.
6
20
 
7
21
  ## Typical trigger
8
22
 
@@ -0,0 +1,32 @@
1
+ # `cmr/rule-selects-every-device`
2
+
3
+ A warning on `execute` or `api` to a CMR controller: a `/cmr/vlan`,
4
+ `/cmr/wifi`, `/cmr/wifi/radio` or `/cmr/upgrade` rule command has no device
5
+ `labels`, so the rule applies to every device the controller manages, the
6
+ controller included. For `/cmr/vlan` it fires only when `port-labels` is
7
+ missing too.
8
+
9
+ ## Typical trigger
10
+
11
+ - `/cmr/vlan/add vlan-ids=10` with no `labels` and no `port-labels`. The
12
+ manual warns that such a rule moves every port of every connected device
13
+ into `cmr-bridge`, the controller included, which cuts the whole fleet's
14
+ management access.
15
+ - `/cmr/wifi/add ssid=Office` with no `labels`, or labels that are only band
16
+ labels (`+5ghz`). The network takes over the radios of every device.
17
+ - `set`, `remove`, `enable` or `disable` of an existing rule that has no
18
+ `labels`. Its reach is every device too.
19
+
20
+ centrs reads the rule's `labels` from the command (`add`) or from the rule
21
+ itself (the other verbs). A rule added without `labels` stores none (7.26beta1
22
+ CHR). The command still runs as typed; the warning rides the envelope, and
23
+ the write gate's prompt and `usage/confirmation-required` summary repeat it.
24
+
25
+ ## Fix
26
+
27
+ Give the rule explicit selectors, for example
28
+ `/cmr/vlan/add labels=office-ap port-labels=ether1 vlan-ids=10 role=trunk`.
29
+ Check which devices a label selects with
30
+ `centrs retrieve <controller> /cmr/device` (`labels`, `auto-labels`). Answer
31
+ `yes` or pass `--yes` only if every device, the controller included, is
32
+ intended.
@@ -0,0 +1,27 @@
1
+ # `cmr/selection-includes-controller`
2
+
3
+ A warning on `execute` or `api` to a CMR controller: a fleet command such as
4
+ `/cmr/device/reboot` or `/cmr/device/run-script` selects the controller's own
5
+ `/cmr/device` row (`controller=true`) as well as its clients. The command acts
6
+ on the controller too.
7
+
8
+ ## Typical trigger
9
+
10
+ - `labels=all`: per the manual, `all` selects every device, the CMR server
11
+ included.
12
+ - An exclusion-only expression such as `labels=-core`, which starts from every
13
+ device.
14
+ - A label the controller also carries, such as its architecture (`x86`) or
15
+ RouterOS version, from `auto-labels`.
16
+
17
+ centrs reads the controller's inventory and evaluates the manual's label
18
+ rules over each row's `labels` and `auto-labels`. On a 7.26beta1 lab this
19
+ matched the device's own selection for every expression tried. The result is
20
+ the controller's current view: a disconnected device's `auto-labels` can be
21
+ stale. `context` names the controller and the number of devices selected.
22
+
23
+ ## Fix
24
+
25
+ Nothing failed; this is information. To leave the controller out, exclude it
26
+ explicitly, for example `labels=all,-<controller identity>` (its identity is
27
+ an automatic label while `/cmr auto-labels` includes identity).
@@ -0,0 +1,34 @@
1
+ # `usage/flag-after-separator`
2
+
3
+ A centrs flag was written after `--`, where every token is RouterOS input.
4
+
5
+ ## Fix
6
+
7
+ Move the flag before `--`. The error's remediation prints the corrected
8
+ command line:
9
+
10
+ ```sh
11
+ centrs execute r1 -- '/system/identity/print' --json # refused
12
+ centrs execute r1 --json -- '/system/identity/print' # fixed
13
+ ```
14
+
15
+ A bare `--` ends centrs option parsing, so without this check a trailing
16
+ `--json` was sent to RouterOS and failed there as `unknown-attribute`. The check
17
+ covers `execute` (including `--cmr`) and `explain`. It matches only a token that
18
+ is exactly one of the command's long flags (or its `--flag=value` spelling);
19
+ short aliases such as `-u` pass through as RouterOS input. If RouterOS really
20
+ needs such a token, put it inside the quoted command. Password values are
21
+ replaced by `<password>` in the printed command line.
22
+ Credentials embedded in a positional target or a `--host` value are redacted
23
+ to `***@host`, including when `--host` is moved before the separator.
24
+ Credentials in an explicit URL are redacted even when its colon is percent-encoded;
25
+ bare CDB identities such as `rtr@site` remain unchanged. A bare target with
26
+ a percent-encoded colon is masked in diagnostics while remaining eligible
27
+ for CDB identity lookup.
28
+
29
+ For boolean flags that accept no value (such as `--json` and `--yes`), the
30
+ error explains how to remove an unsupported `=value` rather than printing
31
+ a command that the parser would reject.
32
+
33
+ See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
34
+ contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.9-next.0",
3
+ "version": "0.1.9-next.22",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -9,12 +9,12 @@
9
9
  },
10
10
  "homepage": "https://github.com/tikoci/centrs#readme",
11
11
  "bugs": "https://github.com/tikoci/centrs/issues",
12
- "packageManager": "bun@1.3.14",
12
+ "packageManager": "bun@1.4.2",
13
13
  "license": "MIT",
14
14
  "devDependencies": {
15
15
  "@biomejs/biome": "2.5.15",
16
16
  "@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
17
- "@types/bun": "1.3.14",
17
+ "@types/bun": "1.4.2",
18
18
  "cloc": "^2.6.0-cloc",
19
19
  "cspell": "^10.3.6",
20
20
  "has-flag": "4.0.0",
@@ -74,12 +74,14 @@
74
74
  "lint:biome": "biome check .",
75
75
  "lint:fix": "biome check --write .",
76
76
  "lint:secretlint": "secretlint \"**/*\"",
77
+ "lint:actions": "uvx zizmor --offline .",
77
78
  "test": "bun test",
78
79
  "test:smoke": "bun test test/integration/cli-smoke.test.ts",
79
80
  "test:integration:fast": "bun test test/integration/devices.test.ts test/integration/settings.test.ts",
80
81
  "test:integration:settings": "bun test test/integration/settings.test.ts",
81
- "test:integration:smoke": "CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/chr-smoke.test.ts",
82
- "test:integration": "CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/",
82
+ "chr:prefetch": "bun run scripts/chr-prefetch.ts",
83
+ "test:integration:smoke": "bun run chr:prefetch && CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/chr-smoke.test.ts",
84
+ "test:integration": "bun run chr:prefetch && CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/",
83
85
  "test:integration:stable": "CENTRS_CHR_CHANNEL=stable bun run test:integration",
84
86
  "test:integration:long-term": "CENTRS_CHR_CHANNEL=long-term bun run test:integration",
85
87
  "test:integration:testing": "CENTRS_CHR_CHANNEL=testing bun run test:integration",
package/src/api-fanout.ts CHANGED
@@ -62,9 +62,11 @@ import {
62
62
  import { promptForWriteConfirmation } from "./execute.ts";
63
63
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
64
64
  import {
65
+ emptyWriteSelectionError,
65
66
  expandSelection,
66
67
  isDefaultRecordTarget,
67
68
  loadEnvFileDefaults,
69
+ redactTargetInput,
68
70
  type SelectionExpansion,
69
71
  type SelectionMember,
70
72
  type SelectionResolveInput,
@@ -87,6 +89,8 @@ export interface ApiFanoutOperationMeta {
87
89
  concurrency: number;
88
90
  summary: FanoutSummary;
89
91
  request: ApiRequestSummary;
92
+ /** Hoisted from the targets when every target used the same login (#461). */
93
+ auth?: { username?: string; passwordProvided: boolean };
90
94
  selection: ApiSelectionSummary;
91
95
  }
92
96
 
@@ -125,7 +129,7 @@ function summarizeSelection(selection: TargetSelection): ApiSelectionSummary {
125
129
  where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
126
130
  all: selection.all,
127
131
  default: selection.default,
128
- positionals: selection.positionals,
132
+ positionals: selection.positionals.map((value) => redactTargetInput(value)),
129
133
  quickchr: selection.quickchr,
130
134
  };
131
135
  }
@@ -242,6 +246,12 @@ export async function apiFanout(
242
246
  const concurrency = resolveFanoutConcurrency(options.concurrency, via);
243
247
 
244
248
  if (expansion.empty) {
249
+ if (requestSummary.write) {
250
+ throw emptyWriteSelectionError(
251
+ expansion,
252
+ `${method} ${requestSummary.path}`,
253
+ );
254
+ }
245
255
  return buildApiFanoutSuccess({
246
256
  summary: { total: 0, ok: 0, failed: 0 },
247
257
  targets: [],