@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
@@ -0,0 +1,29 @@
1
+ # `cmr/outcome-unknown`
2
+
3
+ `execute --cmr` sent the command to a CMR client, but no final status came back
4
+ for it. The command **may have run, may still be running, or may not have
5
+ run**.
6
+
7
+ ## Typical trigger
8
+
9
+ - CMR reported `failed` with `timed out (6)` (the `timeout=` centrs passes, from
10
+ `--timeout`) or `std failure: timeout (13)` (CMR's own ~60 s ceiling per call).
11
+ - centrs stopped waiting: its local deadline passed, the operation was
12
+ cancelled, or the controller connection dropped after the request was sent.
13
+ - The client changed its CMR key (`ids`) during the call, or the controller's
14
+ inventory could not be re-read afterwards to confirm which client answered.
15
+
16
+ On RouterOS 7.26beta1, a timed-out or cancelled `run-script` **kept running on
17
+ the client**, and its side effects landed afterwards (#441, #450). A timeout is
18
+ not a rollback.
19
+
20
+ ## Fix
21
+
22
+ - Check the client's state before doing anything else. Read the menu the
23
+ command changed, directly or through the controller.
24
+ - Do not re-run a command that is not idempotent until you know it did not run.
25
+ centrs never retries a CMR dispatch.
26
+ - For long work, raise `--timeout` (up to 55 s per call). Anything needing more
27
+ than CMR's ~60 s ceiling belongs in a script or scheduler entry on the client.
28
+
29
+ See [`commands/execute/README.md`](../../../commands/execute/README.md) → CMR.
@@ -0,0 +1,16 @@
1
+ # `cmr/output-truncated`
2
+
3
+ A warning: CMR clipped a client's command output at its cap (about 60 KiB on
4
+ 7.26beta1) and appended `...(+N B)`. The command **completed**; only the
5
+ returned text is incomplete. The envelope's `meta.truncated` records it.
6
+
7
+ ## Typical trigger
8
+
9
+ A command that prints a lot, such as a large `print` or `/export`, run with
10
+ `execute --cmr`.
11
+
12
+ ## Fix
13
+
14
+ Narrow the output on the client: `print` with `where` and `proplist=`, or
15
+ `:put` only the fields you need. For a full configuration, save it to a file on
16
+ the client instead of printing it.
@@ -0,0 +1,31 @@
1
+ # `cmr/policy-denied`
2
+
3
+ A CMR client ran the command and refused one of its statements with
4
+ `not enough permissions (9)`.
5
+
6
+ CMR runs `run-script` scripts on each client with only the `read`, `write`,
7
+ `test` and `reboot` policies, and without a user session. Validation cannot
8
+ catch this: the client's `:parse` accepts the command, and the policy check
9
+ happens only when it runs.
10
+
11
+ The refused statement did not run, and the script stopped there. Statements
12
+ before it **did** run: CMR does not roll them back. Their output, followed by
13
+ the diagnostic, is in `context.output`.
14
+
15
+ ## Typical trigger
16
+
17
+ - `/user add`, `/user group add` (needs `policy`).
18
+ - `/tool sniffer` (needs `sniff`).
19
+
20
+ Reading a sensitive value is not this error: it succeeds and returns `*` or an
21
+ empty value.
22
+
23
+ ## Fix
24
+
25
+ Check what the earlier statements changed. Then run only the refused
26
+ statement, and the ones after it, on the device directly with a user that has
27
+ the policy:
28
+
29
+ ```sh
30
+ centrs execute <device> -- '<command>'
31
+ ```
@@ -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,18 @@
1
+ # `cmr/selection-incomplete`
2
+
3
+ centrs asked the controller, for every `/cmr/device` row, whether it matches
4
+ `--labels`. At least one row got no answer, so the selection is not known and
5
+ **nothing was run**.
6
+
7
+ ## Typical trigger
8
+
9
+ A per-device preflight call timed out, was cancelled, or lost its connection
10
+ before the controller either refused it (`no devices selected`, so not a
11
+ member) or returned a row (a member).
12
+
13
+ ## Fix
14
+
15
+ - Check that the controller is reachable and responsive, then retry.
16
+ - Raise `--timeout` if the controller is slow.
17
+ - centrs never runs a partial selection, because a smaller set that reports
18
+ success would hide the clients it skipped.
@@ -0,0 +1,23 @@
1
+ # `cmr/validation-incomplete`
2
+
3
+ The validation result from a CMR client was incomplete, so the command was
4
+ **not** sent to it. The other members are unaffected.
5
+
6
+ ## Typical trigger
7
+
8
+ centrs validates on each client with `:put [:parse <command>]; :put "<nonce>"`
9
+ through `run-script`. The result is rejected before it is classified when:
10
+
11
+ - the status was not `success` (for example `timed out (6)`);
12
+ - the output was clipped by CMR's ~60 KiB cap (`...(+N B)`);
13
+ - the nonce line that proves the output is complete is missing;
14
+ - the `:parse` return was empty or was not parse output (IL).
15
+
16
+ An empty or clipped result is not a pass. Without the completeness check,
17
+ missing output would have read as "no diagnostic".
18
+
19
+ ## Fix
20
+
21
+ - Make sure the client is connected and responsive, then retry.
22
+ - Very large commands can exceed the output cap once `:parse` echoes them back.
23
+ Split the work into smaller commands.
@@ -9,6 +9,10 @@ error.
9
9
  Distinct from `routeros/unsupported-capability`, where the chosen *protocol*
10
10
  cannot carry the operation at all regardless of device version.
11
11
 
12
+ `execute --cmr` raises it with `capability: cmr-controller` when the target has
13
+ no `/cmr/device` and runs RouterOS older than 7.26beta1, where CMR first
14
+ shipped. That check reads the version only after the missing menu, not before.
15
+
12
16
  ## Context fields
13
17
 
14
18
  - `capability` — stable slug of the missing operation.
@@ -0,0 +1,24 @@
1
+ # `transport/write-outcome-unknown`
2
+
3
+ A warning on a failed write. The request had already been sent when the
4
+ transport failed (a dropped connection, a REST 5xx, a timeout), so the router
5
+ may have run it. The error beside this warning says how the call ended, not
6
+ whether the write happened.
7
+
8
+ ## Typical trigger
9
+
10
+ - A connection closed or reset after an `execute` or `api` write was sent.
11
+ - A REST 5xx or a timeout on a write.
12
+
13
+ centrs never retries a write that it has sent, in single-target or fan-out
14
+ mode, because replaying an `add`, a script run or a counter change would apply
15
+ it twice (#448). In fan-out, a write is retried only when a retryable
16
+ transport failure (`transport/network` or `transport/connection-closed`) ends
17
+ it *before* it was sent, for example during remote validation or a native API
18
+ login. Any other failure, sent or not, is not retried.
19
+
20
+ ## Fix
21
+
22
+ Check the router's state before running the write again. For example, read
23
+ back the menu you changed with `centrs retrieve`. Re-run only what did not
24
+ apply.
@@ -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.
@@ -0,0 +1,19 @@
1
+ # `usage/missing-labels`
2
+
3
+ `centrs execute --cmr` was given no `--labels` expression.
4
+
5
+ ## Typical trigger
6
+
7
+ `--cmr` runs the command on the CMR clients a label expression selects, and the
8
+ expression is required so the blast radius is always explicit.
9
+
10
+ ## Fix
11
+
12
+ Pass RouterOS label syntax: a comma list where plain labels are OR, `+label`
13
+ must match, `-label` excludes, and `all` selects every device, including the
14
+ controller:
15
+
16
+ ```sh
17
+ centrs execute ctl --cmr --labels all -- ':put [/system/identity/get name]'
18
+ centrs execute ctl --cmr --labels site-a,+edge,-ctl --yes -- '/ip/dns/set servers=192.0.2.53'
19
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.8",
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
@@ -47,6 +47,7 @@ import {
47
47
  buildFanoutResolveFailure,
48
48
  commonVia,
49
49
  defaultFanoutSleep,
50
+ mayRetryUnsentWrite,
50
51
  resolveFanoutConcurrency,
51
52
  runFanout,
52
53
  summarizeFanout,
@@ -61,9 +62,11 @@ import {
61
62
  import { promptForWriteConfirmation } from "./execute.ts";
62
63
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
63
64
  import {
65
+ emptyWriteSelectionError,
64
66
  expandSelection,
65
67
  isDefaultRecordTarget,
66
68
  loadEnvFileDefaults,
69
+ redactTargetInput,
67
70
  type SelectionExpansion,
68
71
  type SelectionMember,
69
72
  type SelectionResolveInput,
@@ -86,6 +89,8 @@ export interface ApiFanoutOperationMeta {
86
89
  concurrency: number;
87
90
  summary: FanoutSummary;
88
91
  request: ApiRequestSummary;
92
+ /** Hoisted from the targets when every target used the same login (#461). */
93
+ auth?: { username?: string; passwordProvided: boolean };
89
94
  selection: ApiSelectionSummary;
90
95
  }
91
96
 
@@ -105,7 +110,10 @@ export interface ApiFanoutInternals {
105
110
  env: Record<string, string | undefined>,
106
111
  config?: Record<string, string | undefined>,
107
112
  ) => Promise<SelectionExpansion>;
108
- execute?: (resolved: ResolvedApiRequest) => Promise<ApiSuccessEnvelope>;
113
+ execute?: (
114
+ resolved: ResolvedApiRequest,
115
+ trace: ValidationTrace,
116
+ ) => Promise<ApiSuccessEnvelope>;
109
117
  sleep?: (ms: number) => Promise<void>;
110
118
  }
111
119
 
@@ -121,7 +129,7 @@ function summarizeSelection(selection: TargetSelection): ApiSelectionSummary {
121
129
  where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
122
130
  all: selection.all,
123
131
  default: selection.default,
124
- positionals: selection.positionals,
132
+ positionals: selection.positionals.map((value) => redactTargetInput(value)),
125
133
  quickchr: selection.quickchr,
126
134
  };
127
135
  }
@@ -238,6 +246,12 @@ export async function apiFanout(
238
246
  const concurrency = resolveFanoutConcurrency(options.concurrency, via);
239
247
 
240
248
  if (expansion.empty) {
249
+ if (requestSummary.write) {
250
+ throw emptyWriteSelectionError(
251
+ expansion,
252
+ `${method} ${requestSummary.path}`,
253
+ );
254
+ }
241
255
  return buildApiFanoutSuccess({
242
256
  summary: { total: 0, ok: 0, failed: 0 },
243
257
  targets: [],
@@ -318,6 +332,8 @@ export async function apiFanout(
318
332
  ),
319
333
  member,
320
334
  ),
335
+ mayRetry: (resolved) =>
336
+ mayRetryUnsentWrite(requestSummary.write, traces.get(resolved)),
321
337
  sleep,
322
338
  });
323
339