@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
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `centrs --version` / `-V` (#463): the package version, for scripts, agents and
3
+ * field reports.
4
+ *
5
+ * Text output is the bare version string (`0.1.9-next.0`), so a script can use
6
+ * it as is. `--json` / `--format json|yaml` return the standard envelope with
7
+ * `data.version`. Format comes from flags only, never `CENTRS_FORMAT`, so a
8
+ * plain `centrs --version` always prints the bare string.
9
+ */
10
+
11
+ import type { CentrsEnvelope } from "../core/envelope.ts";
12
+ import { centrsVersion } from "../core/version.ts";
13
+ import { toYaml } from "../core/yaml.ts";
14
+ import { CentrsError, formatCentrsErrorText } from "../errors.ts";
15
+
16
+ const versionFormats = ["text", "json", "yaml"] as const;
17
+ type VersionFormat = (typeof versionFormats)[number];
18
+
19
+ export type VersionEnvelope = CentrsEnvelope<
20
+ { version: string },
21
+ { command: "version" }
22
+ >;
23
+
24
+ export function versionEnvelope(): VersionEnvelope {
25
+ return {
26
+ ok: true,
27
+ data: { version: centrsVersion },
28
+ warnings: [],
29
+ tips: [],
30
+ meta: {
31
+ target: {},
32
+ via: null,
33
+ settings: {},
34
+ operation: { command: "version" },
35
+ },
36
+ };
37
+ }
38
+
39
+ function parseVersionFormat(args: readonly string[]): VersionFormat {
40
+ let format: VersionFormat = "text";
41
+ for (let index = 0; index < args.length; index += 1) {
42
+ const arg = args[index];
43
+ if (arg === "--json") {
44
+ format = "json";
45
+ } else if (arg === "--yaml") {
46
+ format = "yaml";
47
+ } else if (arg === "--format") {
48
+ const value = args[++index];
49
+ if (!versionFormats.includes(value as VersionFormat)) {
50
+ throw new CentrsError({
51
+ code: "settings/invalid-format",
52
+ summary: `--format must be one of ${versionFormats.join(", ")}; got ${value ?? "(missing)"}.`,
53
+ remediation: `Choose one of ${versionFormats.join(", ")}.`,
54
+ context: { format: value },
55
+ });
56
+ }
57
+ format = value as VersionFormat;
58
+ } else {
59
+ throw new CentrsError({
60
+ code: "input/invalid-command",
61
+ summary: `\`centrs --version\` takes only an output format; got ${arg}.`,
62
+ remediation:
63
+ "Run `centrs --version`, or `centrs --version --json` for the envelope.",
64
+ context: { arg },
65
+ });
66
+ }
67
+ }
68
+ return format;
69
+ }
70
+
71
+ export function runVersionCli(args: readonly string[]): number {
72
+ let format: VersionFormat;
73
+ try {
74
+ format = parseVersionFormat(args);
75
+ } catch (error) {
76
+ console.error(formatCentrsErrorText(error as CentrsError));
77
+ return 1;
78
+ }
79
+ if (format === "json") {
80
+ console.log(JSON.stringify(versionEnvelope(), null, 2));
81
+ } else if (format === "yaml") {
82
+ console.log(toYaml(versionEnvelope()));
83
+ } else {
84
+ console.log(centrsVersion);
85
+ }
86
+ return 0;
87
+ }
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
12
12
  import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
13
13
  import { runTerminalCli, terminalCommand } from "./cli/terminal.ts";
14
14
  import { runTransferCli, transferCommand } from "./cli/transfer.ts";
15
+ import { runVersionCli } from "./cli/version.ts";
15
16
  import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
16
17
  import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
17
18
 
@@ -48,7 +49,7 @@ export function renderCliHelp(): string {
48
49
  (command) => ` ${command.name.padEnd(12)} ${command.summary}`,
49
50
  ),
50
51
  "",
51
- "Use `centrs <command> --help` for command-specific options.",
52
+ "Use `centrs <command> --help` for command-specific options, and `centrs --version` (`-V`) for the version alone.",
52
53
  ].join("\n");
53
54
  }
54
55
 
@@ -60,6 +61,9 @@ export async function runCli(
60
61
  console.log(renderCliHelp());
61
62
  return 0;
62
63
  }
64
+ if (args[0] === "--version" || args[0] === "-V") {
65
+ return runVersionCli(args.slice(1));
66
+ }
63
67
 
64
68
  const [command, ...rest] = args;
65
69
  if (command === "retrieve") {
@@ -122,7 +126,6 @@ export async function runCli(
122
126
  }
123
127
 
124
128
  console.error(
125
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
126
129
  formatCentrsErrorText(
127
130
  asCentrsError(new Error(`Unknown centrs command: ${command}`), {
128
131
  code: "input/invalid-command",
@@ -137,7 +140,6 @@ export async function runCli(
137
140
  // to a structured envelope, so reaching here means an unconverted throw
138
141
  // escaped. Never let a raw stack trace reach the user.
139
142
  console.error(
140
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
141
143
  formatCentrsErrorText(
142
144
  asCentrsError(error, {
143
145
  code: "internal/unhandled",
@@ -0,0 +1,486 @@
1
+ /**
2
+ * #453: name the fleet a CMR controller command reaches, before its write gate.
3
+ *
4
+ * A controller command such as `/cmr/device/reboot labels=all` is one request
5
+ * to one router, but it acts on every device its `labels`/`numbers` select,
6
+ * the controller's own row included. This module recognizes those commands,
7
+ * reads the controller's `/cmr/device` inventory (read-only), and resolves the
8
+ * selection host-side so the gate can say "N devices, the controller
9
+ * included" and warn on the manual's no-selector hazards. The command itself
10
+ * still runs exactly as typed; nothing here rewrites it.
11
+ *
12
+ * Grounded on a 7.26beta1 two-CHR lab (2026-10-15):
13
+ *
14
+ * - The controller's own row carries `controller=true`.
15
+ * - The manual's label rules evaluated over a row's `labels` ∪ `auto-labels`
16
+ * matched the device's own selection (a `run-script numbers=<id>
17
+ * labels=<expr>` probe per row) in every expression tried: `all`, `-x`,
18
+ * an identity, an architecture, an unknown label, `a,+b`, `a,-b`,
19
+ * `all,-a`, and a case change (labels are case-sensitive).
20
+ * - A row's `auto-labels` can be stale while the device is disconnected, so
21
+ * the result is the controller's current view, which the wording says.
22
+ * - Each row names its `upgrade-rule`, so `/cmr/upgrade/trigger` resolves
23
+ * exactly. `/cmr/upgrade/show-devices` is not reachable over REST (404).
24
+ * - A `/cmr/vlan` or `/cmr/wifi` item added without `labels` stores none.
25
+ * Per the manual, such a VLAN rule (no `port-labels` either) provisions
26
+ * every port of every connected device, the controller included, and an
27
+ * upgrade rule without labels covers every device.
28
+ *
29
+ * Not resolved, so the gate keeps its plain wording: script-shaped input (a
30
+ * positional or `[find …]` selector), an abbreviated selector argument, a
31
+ * console transport (no structured read), `/cmr/upgrade/job/run-next` (the
32
+ * job's device list is not in the inventory), and a failed read.
33
+ */
34
+
35
+ import type { ProtocolAdapter } from "../protocols/adapter.ts";
36
+ import {
37
+ CMR_SCRIPT_POLICIES,
38
+ type CmrCallerPolicy,
39
+ cmrCallerPolicyWarning,
40
+ readCmrCallerPolicy,
41
+ } from "./cmr.ts";
42
+ import type { Warning } from "./envelope.ts";
43
+
44
+ type FleetKind = "device" | "rule" | "trigger" | "job";
45
+
46
+ interface FleetEntry {
47
+ menu: readonly string[];
48
+ verbs: readonly string[];
49
+ kind: FleetKind;
50
+ }
51
+
52
+ const RULE_VERBS = ["add", "set", "remove", "enable", "disable"] as const;
53
+
54
+ /** Controller commands that act on the devices their selection names. */
55
+ const FLEET_COMMANDS: readonly FleetEntry[] = [
56
+ {
57
+ menu: ["cmr", "device"],
58
+ verbs: ["reboot", "upgrade", "pair", "run-script"],
59
+ kind: "device",
60
+ },
61
+ { menu: ["cmr", "upgrade"], verbs: ["trigger"], kind: "trigger" },
62
+ { menu: ["cmr", "upgrade", "job"], verbs: ["run-next"], kind: "job" },
63
+ { menu: ["cmr", "upgrade"], verbs: RULE_VERBS, kind: "rule" },
64
+ { menu: ["cmr", "vlan"], verbs: RULE_VERBS, kind: "rule" },
65
+ { menu: ["cmr", "wifi"], verbs: RULE_VERBS, kind: "rule" },
66
+ { menu: ["cmr", "wifi", "radio"], verbs: RULE_VERBS, kind: "rule" },
67
+ ];
68
+
69
+ /**
70
+ * The policies a fleet command runs with on the clients, where #456 measured
71
+ * that RouterOS does not check them against the controller login (7.26beta1):
72
+ * `run-script` needs only `read` + `api`, `reboot` only `read` + `test` +
73
+ * `api`. Other fleet commands were not measured, so they carry no warning.
74
+ */
75
+ const RUNS_WITH: Readonly<Record<string, readonly string[]>> = {
76
+ "/cmr/device/run-script": CMR_SCRIPT_POLICIES,
77
+ "/cmr/device/reboot": ["reboot"],
78
+ };
79
+
80
+ /** WiFi band labels select radios, not devices (manual: `+2ghz`, `+5ghz`, `+6ghz`). */
81
+ const BAND_LABEL = /^\+[256]ghz$/;
82
+
83
+ const SELECTOR_ARGUMENTS = ["labels", "port-labels", "numbers", "number"];
84
+
85
+ /** A recognized fleet command, before anything is read. */
86
+ export interface CmrFleetCommand {
87
+ /** Full spelling, e.g. `/cmr/device/reboot` (abbreviations expanded). */
88
+ command: string;
89
+ menu: string;
90
+ verb: string;
91
+ kind: FleetKind;
92
+ attributes: Readonly<Record<string, string>>;
93
+ }
94
+
95
+ /** One `/cmr/device` row the selection reaches. */
96
+ export interface CmrFleetDevice {
97
+ id: string;
98
+ identity: string;
99
+ controller: boolean;
100
+ connected: boolean;
101
+ }
102
+
103
+ /** What the gate says about a fleet command. */
104
+ export interface CmrFleetScope {
105
+ command: string;
106
+ /** The devices reached, by the controller's current inventory; absent when not resolved. */
107
+ devices?: CmrFleetDevice[];
108
+ /** Size of the controller's `/cmr/device` inventory. */
109
+ inventory?: number;
110
+ /** Why `devices` is absent. */
111
+ unresolved?: string;
112
+ /** A rule without a device selector (applies to every device). */
113
+ everyDevice?: boolean;
114
+ }
115
+
116
+ /**
117
+ * Recognize a fleet command from a structured path (`/cmr/device/reboot`,
118
+ * each segment possibly abbreviated as RouterOS accepts) and its attributes.
119
+ */
120
+ export function cmrFleetCommand(
121
+ path: string,
122
+ attributes: Readonly<Record<string, string>>,
123
+ ): CmrFleetCommand | undefined {
124
+ const segments = path
125
+ .split("/")
126
+ .filter((segment) => segment.length > 0)
127
+ .map((segment) => segment.toLowerCase());
128
+ for (const entry of FLEET_COMMANDS) {
129
+ if (segments.length !== entry.menu.length + 1) continue;
130
+ const menuMatches = entry.menu.every((name, k) =>
131
+ name.startsWith(segments[k] as string),
132
+ );
133
+ if (!menuMatches) continue;
134
+ const typed = segments.at(-1) as string;
135
+ const verbs = entry.verbs.filter((verb) => verb.startsWith(typed));
136
+ const verb = verbs.includes(typed) ? typed : verbs[0];
137
+ if (verb === undefined || (verbs.length > 1 && !verbs.includes(typed))) {
138
+ continue;
139
+ }
140
+ const menu = `/${entry.menu.join("/")}`;
141
+ return {
142
+ command: `${menu}/${verb}`,
143
+ menu,
144
+ verb,
145
+ kind: entry.kind,
146
+ attributes,
147
+ };
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ /**
153
+ * Whether a comma-separated CMR `labels` expression selects a device with
154
+ * these labels. Plain labels OR together, `+label` must also match, `-label`
155
+ * excludes, `all` matches every device, and an expression with no plain label
156
+ * starts from every device (`-core` = `all,-core`). Case-sensitive.
157
+ */
158
+ export function cmrLabelsSelect(
159
+ expression: string,
160
+ labels: ReadonlySet<string>,
161
+ ): boolean {
162
+ let plain = false;
163
+ let matched = false;
164
+ for (const raw of expression.split(",")) {
165
+ const item = raw.trim();
166
+ if (item.length === 0) continue;
167
+ if (item.startsWith("+")) {
168
+ if (!labels.has(item.slice(1))) return false;
169
+ } else if (item.startsWith("-")) {
170
+ if (labels.has(item.slice(1))) return false;
171
+ } else {
172
+ plain = true;
173
+ if (item === "all" || labels.has(item)) matched = true;
174
+ }
175
+ }
176
+ return plain ? matched : true;
177
+ }
178
+
179
+ type Row = Record<string, string>;
180
+
181
+ function labelSet(row: Row): Set<string> {
182
+ return new Set(
183
+ `${row["labels"] ?? ""},${row["auto-labels"] ?? ""}`
184
+ .split(",")
185
+ .map((label) => label.trim())
186
+ .filter((label) => label.length > 0),
187
+ );
188
+ }
189
+
190
+ function toDevice(row: Row): CmrFleetDevice {
191
+ return {
192
+ id: row[".id"] ?? "",
193
+ identity: row["identity"] ?? "",
194
+ controller: row["controller"] === "true",
195
+ connected: row["connected"] === "true",
196
+ };
197
+ }
198
+
199
+ function listItems(value: string): string[] {
200
+ return value
201
+ .split(",")
202
+ .map((item) => item.trim())
203
+ .filter((item) => item.length > 0);
204
+ }
205
+
206
+ /** A `numbers=` item names a row by `.id` (any case) or by its `name`/`identity`. */
207
+ function rowNamed(row: Row, item: string, nameField: string): boolean {
208
+ return (
209
+ row[".id"]?.toLowerCase() === item.toLowerCase() || row[nameField] === item
210
+ );
211
+ }
212
+
213
+ /** Drop band labels from a WiFi expression; they pick radios, not devices. */
214
+ function deviceLabels(command: CmrFleetCommand, labels: string): string {
215
+ if (!command.menu.startsWith("/cmr/wifi")) return labels;
216
+ return listItems(labels)
217
+ .filter((item) => !BAND_LABEL.test(item))
218
+ .join(",");
219
+ }
220
+
221
+ function abbreviatedSelector(
222
+ attributes: Readonly<Record<string, string>>,
223
+ ): string | undefined {
224
+ return Object.keys(attributes).find(
225
+ (key) =>
226
+ !SELECTOR_ARGUMENTS.includes(key) &&
227
+ SELECTOR_ARGUMENTS.some((name) => name.startsWith(key)),
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Resolve which inventory rows a fleet command reaches. `read` returns a
233
+ * menu's rows (`/cmr/device`, or the rule's own menu for `set`/`remove`).
234
+ */
235
+ export async function resolveCmrFleetScope(
236
+ command: CmrFleetCommand,
237
+ read: (menu: string) => Promise<Row[]>,
238
+ ): Promise<CmrFleetScope> {
239
+ const scope = (fields: Omit<CmrFleetScope, "command">): CmrFleetScope => ({
240
+ command: command.command,
241
+ ...fields,
242
+ });
243
+ const abbreviated = abbreviatedSelector(command.attributes);
244
+ if (abbreviated !== undefined) {
245
+ return scope({
246
+ unresolved: `the abbreviated argument \`${abbreviated}=\` was not expanded`,
247
+ });
248
+ }
249
+ if (command.kind === "job") {
250
+ return scope({
251
+ unresolved: "an upgrade job's devices are not in the inventory",
252
+ });
253
+ }
254
+ const rows = await read("/cmr/device");
255
+ const inventory = rows.length;
256
+ const select = (labels: string | undefined, numbers?: string) => {
257
+ const ids = numbers === undefined ? undefined : listItems(numbers);
258
+ return rows
259
+ .filter(
260
+ (row) =>
261
+ (ids === undefined ||
262
+ ids.some((item) => rowNamed(row, item, "identity"))) &&
263
+ (labels === undefined || cmrLabelsSelect(labels, labelSet(row))),
264
+ )
265
+ .map(toDevice);
266
+ };
267
+ const { attributes } = command;
268
+
269
+ if (command.kind === "device") {
270
+ const labels = attributes["labels"]?.trim() || undefined;
271
+ const numbers = attributes["numbers"]?.trim() || undefined;
272
+ if (labels === undefined && numbers === undefined) {
273
+ return scope({ inventory, unresolved: "no `numbers` or `labels`" });
274
+ }
275
+ return scope({ inventory, devices: select(labels, numbers) });
276
+ }
277
+
278
+ if (command.kind === "trigger") {
279
+ const numbers = attributes["numbers"]?.trim();
280
+ if (!numbers) return scope({ inventory, unresolved: "no `numbers`" });
281
+ const rules = await read("/cmr/upgrade");
282
+ const names = new Set(
283
+ listItems(numbers).flatMap((item) =>
284
+ rules
285
+ .filter((rule) => rowNamed(rule, item, "name"))
286
+ .map((rule) => rule["name"] ?? ""),
287
+ ),
288
+ );
289
+ return scope({
290
+ inventory,
291
+ devices: rows
292
+ .filter((row) => names.has(row["upgrade-rule"] ?? ""))
293
+ .map(toDevice),
294
+ });
295
+ }
296
+
297
+ // A rule: `add` uses its own selectors; the others act on existing items,
298
+ // and a `set` that changes a selector reaches the old and the new state.
299
+ // Each state is judged on its own: one unscoped rule among several still
300
+ // reaches every device.
301
+ const states: { labels?: string; portLabels?: string }[] = [];
302
+ if (command.verb === "add") {
303
+ states.push({
304
+ labels: attributes["labels"],
305
+ portLabels: attributes["port-labels"],
306
+ });
307
+ } else {
308
+ const numbers = attributes["numbers"]?.trim();
309
+ if (!numbers) return scope({ inventory, unresolved: "no `numbers`" });
310
+ const items = await read(command.menu);
311
+ const named = listItems(numbers).map((item) =>
312
+ items.find((rule) => rowNamed(rule, item, "name")),
313
+ );
314
+ if (named.some((rule) => rule === undefined)) {
315
+ return scope({
316
+ inventory,
317
+ unresolved: `\`numbers=${numbers}\` did not match a ${command.menu} item`,
318
+ });
319
+ }
320
+ for (const rule of named as Row[]) {
321
+ const old = { labels: rule["labels"], portLabels: rule["port-labels"] };
322
+ states.push(old);
323
+ if (
324
+ command.verb === "set" &&
325
+ ("labels" in attributes || "port-labels" in attributes)
326
+ ) {
327
+ states.push({
328
+ labels: attributes["labels"] ?? old.labels,
329
+ portLabels: attributes["port-labels"] ?? old.portLabels,
330
+ });
331
+ }
332
+ }
333
+ }
334
+ const reached = new Map<string, CmrFleetDevice>();
335
+ let everyDevice = false;
336
+ for (const state of states) {
337
+ const labels = deviceLabels(command, state.labels?.trim() ?? "");
338
+ // VLAN: no `labels` but `port-labels` still narrows which ports change.
339
+ const narrowedByPort =
340
+ command.menu === "/cmr/vlan" && (state.portLabels?.trim() ?? "") !== "";
341
+ if (labels.length === 0 && !narrowedByPort) everyDevice = true;
342
+ for (const device of select(labels || "all")) {
343
+ reached.set(device.id, device);
344
+ }
345
+ }
346
+ return scope({ inventory, devices: [...reached.values()], everyDevice });
347
+ }
348
+
349
+ /**
350
+ * The gate's phrase for a scope, e.g. `2 CMR device(s) through controller
351
+ * ctl, the controller itself among them`.
352
+ */
353
+ function describeReach(scope: CmrFleetScope, controller: string): string {
354
+ if (scope.devices === undefined) {
355
+ return `the CMR devices it selects through controller ${controller} (not resolved: ${scope.unresolved})`;
356
+ }
357
+ const self = scope.devices.some((device) => device.controller);
358
+ return `${scope.devices.length} CMR device(s) through controller ${controller}${self ? ", the controller itself among them" : ""}`;
359
+ }
360
+
361
+ /** Warnings for a resolved scope: the controller in it, every device, nothing. */
362
+ export function cmrFleetWarnings(scope: CmrFleetScope): Warning[] {
363
+ const warnings: Warning[] = [];
364
+ if (scope.everyDevice) {
365
+ const subject = scope.command.endsWith("/add")
366
+ ? `\`${scope.command}\` adds a rule`
367
+ : `\`${scope.command}\` acts on a rule`;
368
+ warnings.push({
369
+ code: "cmr/rule-selects-every-device",
370
+ message: scope.command.startsWith("/cmr/vlan/")
371
+ ? `${subject} with no \`labels\` and no \`port-labels\`: it provisions every port of every connected device, the controller included, which cuts the fleet's management access.`
372
+ : `${subject} with no device \`labels\`: it applies to every device the controller manages, the controller included.`,
373
+ context: { command: scope.command, inventory: scope.inventory },
374
+ });
375
+ }
376
+ if (scope.devices === undefined) return warnings;
377
+ const controller = scope.devices.find((device) => device.controller);
378
+ if (controller && !scope.everyDevice) {
379
+ warnings.push({
380
+ code: "cmr/selection-includes-controller",
381
+ message: `\`${scope.command}\` selects the controller's own row (\`${controller.identity}\`) as well as ${scope.devices.length - 1} other device(s).`,
382
+ context: {
383
+ command: scope.command,
384
+ controller: controller.identity,
385
+ devices: scope.devices.length,
386
+ },
387
+ });
388
+ }
389
+ if (scope.devices.length === 0) {
390
+ warnings.push({
391
+ code: "cmr/empty-selection",
392
+ message: `\`${scope.command}\` selects no device in the controller's inventory now. Labels are case-sensitive and RouterOS accepts unknown ones silently.`,
393
+ context: { command: scope.command, inventory: scope.inventory },
394
+ });
395
+ }
396
+ return warnings;
397
+ }
398
+
399
+ /** The gate's error context for a fleet scope. */
400
+ export function cmrFleetContext(scope: CmrFleetScope): Record<string, unknown> {
401
+ return {
402
+ command: scope.command,
403
+ ...(scope.devices === undefined
404
+ ? { unresolved: scope.unresolved }
405
+ : {
406
+ devices: scope.devices.length,
407
+ connected: scope.devices.filter((device) => device.connected).length,
408
+ controllerIncluded: scope.devices.some((device) => device.controller),
409
+ identities: scope.devices.map((device) => device.identity),
410
+ }),
411
+ ...(scope.inventory === undefined ? {} : { inventory: scope.inventory }),
412
+ };
413
+ }
414
+
415
+ /** Result of {@link readCmrFleet}: the scope and the warnings it raises. */
416
+ export interface CmrFleetRead {
417
+ scope: CmrFleetScope;
418
+ warnings: Warning[];
419
+ }
420
+
421
+ /**
422
+ * The write gate's wording for a fleet command: the TTY prompt and the
423
+ * refusal summary name the reach and repeat each warning, since a person
424
+ * answering the prompt never sees the envelope's `warnings`.
425
+ */
426
+ export function cmrFleetGate(
427
+ fleet: CmrFleetRead,
428
+ controller: string,
429
+ ): { prompt: string; summary: string; context: Record<string, unknown> } {
430
+ const reach = describeReach(fleet.scope, controller);
431
+ const notes = fleet.warnings
432
+ .filter((warning) => warning.code !== "cmr/selection-includes-controller")
433
+ .map((warning) => ` ${warning.message}`)
434
+ .join("");
435
+ return {
436
+ prompt: `Run write-shaped \`${fleet.scope.command}\` on ${reach}?${notes} Type yes to continue: `,
437
+ summary: `A write-shaped \`${fleet.scope.command}\` would reach ${reach}, and needs explicit confirmation.${notes}`,
438
+ context: { cmr: cmrFleetContext(fleet.scope) },
439
+ };
440
+ }
441
+
442
+ /** Whether an adapter for this transport can do the structured scope read. */
443
+ export function cmrFleetReadable(via: string): boolean {
444
+ return via === "native-api" || via === "rest-api";
445
+ }
446
+
447
+ /**
448
+ * Read a fleet command's scope (and, for `run-script`/`reboot`, the caller's
449
+ * policy) on the controller. Best effort: a failed read leaves the scope
450
+ * unresolved and the gate as strict as before.
451
+ */
452
+ export async function readCmrFleet(
453
+ command: CmrFleetCommand,
454
+ adapter: Pick<ProtocolAdapter, "apiRequest">,
455
+ username: string | undefined,
456
+ ): Promise<CmrFleetRead> {
457
+ const read = async (menu: string) => {
458
+ const result = await adapter.apiRequest({ verb: "print", path: menu });
459
+ return Array.isArray(result.data) ? (result.data as Row[]) : [];
460
+ };
461
+ let scope: CmrFleetScope;
462
+ let unreachable = false;
463
+ try {
464
+ scope = await resolveCmrFleetScope(command, read);
465
+ } catch (error) {
466
+ const code =
467
+ error instanceof Error && "code" in error ? String(error.code) : "error";
468
+ // The policy read would wait out the same dead controller (or login).
469
+ unreachable = code.startsWith("transport/") || code.startsWith("auth/");
470
+ scope = {
471
+ command: command.command,
472
+ unresolved: `the controller read failed (${code})`,
473
+ };
474
+ }
475
+ const warnings = cmrFleetWarnings(scope);
476
+ const runsWith = RUNS_WITH[command.command];
477
+ if (runsWith !== undefined && username && !unreachable) {
478
+ const caller: CmrCallerPolicy | undefined = await readCmrCallerPolicy(
479
+ adapter,
480
+ username,
481
+ );
482
+ const warning = caller && cmrCallerPolicyWarning(caller, runsWith);
483
+ if (warning) warnings.push(warning);
484
+ }
485
+ return { scope, warnings };
486
+ }