@tikoci/centrs 0.1.8 → 0.1.9-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +4 -0
  3. package/commands/api/README.md +5 -0
  4. package/commands/execute/README.md +117 -0
  5. package/commands/execute/examples.md +162 -0
  6. package/commands/retrieve/README.md +2 -1
  7. package/docs/CLI.md +2 -0
  8. package/docs/CONSTITUTION.md +29 -1
  9. package/docs/MATRIX.md +19 -3
  10. package/docs/errors/cmr/cancelled.md +15 -0
  11. package/docs/errors/cmr/client-disconnected.md +19 -0
  12. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  13. package/docs/errors/cmr/console-only.md +29 -0
  14. package/docs/errors/cmr/empty-selection.md +19 -0
  15. package/docs/errors/cmr/membership-changed.md +20 -0
  16. package/docs/errors/cmr/not-a-controller.md +26 -0
  17. package/docs/errors/cmr/outcome-unknown.md +29 -0
  18. package/docs/errors/cmr/output-truncated.md +16 -0
  19. package/docs/errors/cmr/policy-denied.md +31 -0
  20. package/docs/errors/cmr/selection-incomplete.md +18 -0
  21. package/docs/errors/cmr/validation-incomplete.md +23 -0
  22. package/docs/errors/routeros/version-unsupported.md +4 -0
  23. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  24. package/docs/errors/usage/missing-labels.md +19 -0
  25. package/package.json +1 -1
  26. package/src/api-fanout.ts +7 -1
  27. package/src/api.ts +25 -6
  28. package/src/cli/execute.ts +132 -1
  29. package/src/core/cmr.ts +104 -0
  30. package/src/core/envelope.ts +16 -0
  31. package/src/core/error-catalog.ts +71 -0
  32. package/src/core/fanout.ts +77 -8
  33. package/src/errors.ts +1 -0
  34. package/src/execute-cmr.ts +1425 -0
  35. package/src/execute-fanout.ts +4 -0
  36. package/src/execute.ts +68 -17
  37. package/src/index.ts +17 -0
  38. package/src/protocols/adapter.ts +54 -12
@@ -0,0 +1,1425 @@
1
+ /**
2
+ * `execute --cmr`: run one validated RouterOS command on CMR clients through
3
+ * their controller (#450).
4
+ *
5
+ * CMR (RouterOS 7.26beta1+) keeps a device inventory on a controller and runs
6
+ * scripts on paired clients with `/cmr/device/run-script`. centrs talks only to
7
+ * the controller, with the controller's credentials; a client's address is
8
+ * never dialed. CMR is a selection source here, not a protocol: `--via` pins
9
+ * the controller transport.
10
+ *
11
+ * Grounded on 7.26beta1 x86 CHR (#441 and the #450 probes; the device facts
12
+ * this module relies on are asserted in `test/integration/execute-cmr.test.ts`):
13
+ *
14
+ * - `run-script` rows are `{.section, device, status, output}` progress
15
+ * snapshots. They carry NO device id, and `device` is the controller's
16
+ * cached `identity@address`, which changed mid-call when a client dropped.
17
+ * So every call here selects exactly ONE device (`numbers=<.id>`): the rows
18
+ * of that call are that device's, with nothing to correlate.
19
+ * - `numbers` and `labels` together select their intersection; an empty one
20
+ * is refused up front with `failure: no devices selected` (nothing sent).
21
+ * That refusal is how a preflight call learns "not a member", and how a
22
+ * dispatch learns the label set moved since preflight.
23
+ * - `failed` with `timed out (6)` or `std failure: timeout (13)` (a ~60 s
24
+ * ceiling `timeout=` does not lift), and a cancelled call, all left the
25
+ * client script RUNNING; its side effects landed later. A call that did not
26
+ * end in a final row is an unknown outcome, never "did not run", and is
27
+ * never retried.
28
+ * - Output is clipped at ~60 KiB with an in-band `...(+N B)` and status
29
+ * `success`. A validation result therefore passes a completeness gate (a
30
+ * trailing nonce line) before its `:parse` text is classified.
31
+ * - Scripts run with only the `read`, `write`, `test` and `reboot` policies
32
+ * (no user session). `/user/add` and `/tool/sniffer` fail at RUN time with
33
+ * `not enough permissions (9)` although `:parse` accepted them, so that is
34
+ * a dispatch outcome (`cmr/policy-denied`), never a validation result.
35
+ * - A client cannot check its own key: inside `run-script`,
36
+ * `/cmr/client get local-id` reads `*****` (no `sensitive` policy), and
37
+ * `numbers=` takes `.id` or identity but not `ids`. So binding is a
38
+ * host-side `/cmr/device` read just before each call, not an atomic check.
39
+ * - `freeze-frame-interval=20ms` brings a trivial call from ~1 s to ~25 ms;
40
+ * `strategy`/`fail-policy` are pinned and `order` is never sent (it adds
41
+ * `pending` and policy-`skipped` statuses).
42
+ */
43
+
44
+ import { assertNotCmrDashboardIl, CMR_RUN_SCRIPT_PATH } from "./core/cmr.ts";
45
+ import type {
46
+ CentrsErrorEnvelope,
47
+ CentrsSuccessEnvelope,
48
+ CommonSettingsMeta,
49
+ EnvelopeTargetMeta,
50
+ EnvelopeValidationMeta,
51
+ FanoutData,
52
+ FanoutSummary,
53
+ Warning,
54
+ } from "./core/envelope.ts";
55
+ import { notReachedReason } from "./core/envelope.ts";
56
+ import { isWriteShaped } from "./core/execute-command.ts";
57
+ import {
58
+ NOT_SENT_TRANSPORT_CODES,
59
+ resolveFanoutConcurrency,
60
+ runBoundedPool,
61
+ summarizeFanout,
62
+ } from "./core/fanout.ts";
63
+ import {
64
+ assertCapability,
65
+ type CapabilityRequirement,
66
+ probeDeviceVersion,
67
+ } from "./core/preflight.ts";
68
+ import { mapRouterOsError } from "./core/routeros-errors.ts";
69
+ import { routerOsStringLiteral } from "./core/routeros-string.ts";
70
+ import { toYaml } from "./core/yaml.ts";
71
+ import {
72
+ CentrsError,
73
+ formatCentrsErrorText,
74
+ serializeCentrsError,
75
+ terminalSafeLine,
76
+ } from "./errors.ts";
77
+ import {
78
+ disabledValidationMeta,
79
+ type ExecuteEnvelope,
80
+ type ExecuteOperationMeta,
81
+ type ExecuteRequest,
82
+ type ExecuteRequestSummary,
83
+ offlineStagePassed,
84
+ passedStages,
85
+ promptForWriteConfirmation,
86
+ type ResolvedExecuteRequest,
87
+ renderExecuteEnvelope,
88
+ resolveExecuteRequest,
89
+ } from "./execute.ts";
90
+ import { assertOfflineSyntax, type OfflineGateResult } from "./offline-gate.ts";
91
+ import {
92
+ createProtocolAdapter,
93
+ type ProtocolAdapter,
94
+ type RouterOsProtocol,
95
+ } from "./protocols/index.ts";
96
+ import { classifyParseResult } from "./protocols/mac-telnet-console.ts";
97
+ import { toCoreSource } from "./resolver/index.ts";
98
+
99
+ /** The device-stage validator identity for a CMR client. */
100
+ export const CMR_VALIDATION_SOURCE = ":put [:parse ...] via cmr run-script";
101
+
102
+ /** CMR's hard per-call ceiling is ~60 s; stay under it so `timeout=` decides. */
103
+ export const CMR_MAX_CALL_TIMEOUT_MS = 55_000;
104
+
105
+ /** Local grace past the CMR `timeout=` before centrs stops waiting. */
106
+ export const CMR_CALL_GRACE_MS = 3_000;
107
+
108
+ /** Fixed per-call arguments (see the module header for why). */
109
+ const RUN_SCRIPT_PINNED = {
110
+ "freeze-frame-interval": "20ms",
111
+ strategy: "parallel",
112
+ "fail-policy": "continue",
113
+ } as const;
114
+
115
+ const FINAL_STATUSES = new Set(["success", "failed", "skipped"]);
116
+ const TRUNCATION_MARKER = /\.\.\.\(\+(\d+) B\)\s*$/;
117
+ const TIMEOUT_OUTPUT =
118
+ /^(?:timed out \(\d+\)|std failure: timeout \(\d+\))\s*$/;
119
+ const NO_DEVICES_SELECTED = /no devices selected/i;
120
+ /** run-script lacks `policy`, `sniff`, `sensitive` (…); `:parse` cannot see that. */
121
+ const POLICY_DENIED = /^not enough permissions \(9\).*$/m;
122
+ /** An invocation, optionally after a relative menu change (`/ip/address/;(evl …`). */
123
+ const ACCEPTED_IL = /^(?:\/[^\s;()]*;)?\(evl\s/;
124
+ /** A missing `/cmr/device`: native `no such command prefix`, REST `no such command or directory (cmr)`. */
125
+ const MISSING_MENU = /no such command (?:prefix|or directory)/i;
126
+
127
+ export interface ExecuteCmrRequest extends ExecuteRequest {
128
+ /** RouterOS label expression (comma list; plain = OR, `+` AND, `-` AND NOT, `all`). */
129
+ labels: string;
130
+ /** Max controller calls in flight (default: the transport fan-out default). */
131
+ concurrency?: number;
132
+ /** Cancels the operation; members already sent become unknown outcomes. */
133
+ signal?: AbortSignal;
134
+ }
135
+
136
+ /** One `/cmr/device` row, reduced to what binding needs. */
137
+ export interface CmrDevice {
138
+ id: string;
139
+ /** `<device key>/<controller key>` (hex); stable across rename and re-pair on 7.26beta1. */
140
+ ids: string;
141
+ identity: string;
142
+ address?: string;
143
+ }
144
+
145
+ export interface CmrScriptRow {
146
+ device: string;
147
+ status: string;
148
+ output: string;
149
+ section: number;
150
+ }
151
+
152
+ export interface CmrScriptCall {
153
+ numbers: string;
154
+ labels: string;
155
+ script: string;
156
+ /** CMR `timeout=` in ms; the local wait adds {@link CMR_CALL_GRACE_MS}. */
157
+ timeoutMs: number;
158
+ }
159
+
160
+ /**
161
+ * What one single-device `run-script` call produced. `selected: false` is the
162
+ * controller's up-front refusal (nothing sent). Otherwise `row` is the newest
163
+ * row seen, and `error` says why the call ended without one being final.
164
+ */
165
+ export interface CmrScriptOutcome {
166
+ selected: boolean;
167
+ /** The request reached the controller (native: the sentence was written). */
168
+ sent: boolean;
169
+ row?: CmrScriptRow;
170
+ error?: CentrsError;
171
+ }
172
+
173
+ /** Controller seam: production wraps a protocol adapter; tests inject a fake. */
174
+ /** What a router without `/cmr/device` says about itself, read only on that failure. */
175
+ export interface CmrAbsence {
176
+ /** `/system/resource` version. */
177
+ version?: string;
178
+ /** `/cmr/client` `controller-identity` / `controller-address`, when it is a client. */
179
+ controllerIdentity?: string;
180
+ controllerAddress?: string;
181
+ }
182
+
183
+ export interface CmrController {
184
+ devices(): Promise<CmrDevice[]>;
185
+ /** Explain a missing `/cmr/device`; each read is best effort. */
186
+ absence?(): Promise<CmrAbsence>;
187
+ runScript(
188
+ call: CmrScriptCall,
189
+ signal: AbortSignal,
190
+ ): Promise<CmrScriptOutcome>;
191
+ close(): Promise<void>;
192
+ }
193
+
194
+ export interface ExecuteCmrInternals {
195
+ controller?: (
196
+ resolved: ResolvedExecuteRequest,
197
+ signal: AbortSignal,
198
+ ) => CmrController;
199
+ nonce?: () => string;
200
+ }
201
+
202
+ export interface ExecuteCmrOperationMeta {
203
+ kind: "cmr";
204
+ concurrency: number;
205
+ summary: FanoutSummary;
206
+ request: ExecuteRequestSummary;
207
+ cmr: {
208
+ labels: string;
209
+ /** `/cmr/device` rows considered. */
210
+ inventory: number;
211
+ /** Rows the label expression selected. */
212
+ selected: number;
213
+ /** Members the command was sent to. */
214
+ dispatched: number;
215
+ };
216
+ }
217
+
218
+ export type ExecuteCmrData = FanoutData<unknown, ExecuteOperationMeta>;
219
+ export type ExecuteCmrEnvelope = CentrsSuccessEnvelope<
220
+ ExecuteCmrData,
221
+ ExecuteCmrOperationMeta
222
+ >;
223
+ export type ExecuteCmrErrorEnvelope =
224
+ CentrsErrorEnvelope<ExecuteCmrOperationMeta>;
225
+
226
+ interface Member {
227
+ device: CmrDevice;
228
+ /** Final envelope once decided; undefined while still eligible to run. */
229
+ result?: ExecuteEnvelope;
230
+ validation: EnvelopeValidationMeta;
231
+ }
232
+
233
+ /**
234
+ * Run `request.command` on every CMR client `request.labels` selects on the
235
+ * controller `request` resolves to. Throws (outer error) only before anything
236
+ * is dispatched: bad usage, an incomplete selection, a refused write gate.
237
+ */
238
+ export async function executeCmr(
239
+ request: ExecuteCmrRequest,
240
+ env: Record<string, string | undefined> = Bun.env,
241
+ internals: ExecuteCmrInternals = {},
242
+ ): Promise<ExecuteCmrEnvelope> {
243
+ const labels = request.labels.trim();
244
+ if (labels.length === 0) {
245
+ throw new CentrsError({
246
+ code: "usage/missing-labels",
247
+ summary:
248
+ "`--cmr` needs a `--labels` expression naming the clients to run on.",
249
+ remediation:
250
+ "Pass RouterOS label syntax, e.g. `--labels all`, `--labels site-a,+edge` or `--labels lab,-controller`.",
251
+ });
252
+ }
253
+ const resolved = await resolveExecuteRequest(request, env);
254
+ const via = resolved.via.value;
255
+ if (via !== "native-api" && via !== "rest-api") {
256
+ throw new CentrsError({
257
+ code: "usage/conflicting-flags",
258
+ summary: `\`--cmr\` talks to the controller over native-api or rest-api, not ${via}.`,
259
+ remediation:
260
+ "Drop `--via` (native-api is the default) or pin `--via native-api` / `--via rest-api`.",
261
+ context: { via },
262
+ });
263
+ }
264
+ const offline = resolved.validate.value
265
+ ? assertOfflineSyntax(resolved.command, { surface: "execute", via })
266
+ : undefined;
267
+ const signal = request.signal ?? new AbortController().signal;
268
+ const concurrency = resolveFanoutConcurrency(request.concurrency, via);
269
+ const callTimeoutMs = Math.min(
270
+ resolved.timeoutMs.value,
271
+ CMR_MAX_CALL_TIMEOUT_MS,
272
+ );
273
+ const write = isWriteShaped(resolved.canonical);
274
+ const controllerName =
275
+ resolved.target.identity ?? resolved.target.input ?? resolved.target.host;
276
+ const controller = (internals.controller ?? adapterController)(
277
+ resolved,
278
+ signal,
279
+ );
280
+ const nonce = (internals.nonce ?? defaultNonce)();
281
+
282
+ try {
283
+ // 1. Inventory and per-row preflight (membership + client validation).
284
+ const before = await readInventoryBeforeDispatch(controller, signal);
285
+ const preflightScript = resolved.validate.value
286
+ ? `:put [:parse ${routerOsStringLiteral(resolved.command)}]; :put "${nonce}"`
287
+ : `:put "${nonce}"`;
288
+ const preflight = await runBoundedPool(before, concurrency, (device) =>
289
+ signal.aborted
290
+ ? Promise.resolve<CmrScriptOutcome>({
291
+ selected: true,
292
+ sent: false,
293
+ error: cancelledError(),
294
+ })
295
+ : controller.runScript(
296
+ {
297
+ numbers: device.id,
298
+ labels,
299
+ script: preflightScript,
300
+ timeoutMs: callTimeoutMs,
301
+ },
302
+ signal,
303
+ ),
304
+ );
305
+ if (signal.aborted) throw cancelledError();
306
+ const unknownMembership = before.filter(
307
+ (_, index) =>
308
+ preflight[index]?.selected === true &&
309
+ preflight[index]?.row === undefined,
310
+ );
311
+ if (unknownMembership.length > 0) {
312
+ throw new CentrsError({
313
+ code: "cmr/selection-incomplete",
314
+ summary: `The controller did not say whether ${unknownMembership.length} CMR device(s) match \`${labels}\`, so nothing was run.`,
315
+ remediation:
316
+ "Check the controller is reachable and responsive, raise `--timeout`, then retry. A partial selection is never run.",
317
+ context: {
318
+ labels,
319
+ devices: unknownMembership.map((d) => ({
320
+ id: d.id,
321
+ identity: d.identity,
322
+ })),
323
+ causes: unknownMembership.map((d) => {
324
+ const error = preflight[before.indexOf(d)]?.error;
325
+ return error ? error.code : "no reply";
326
+ }),
327
+ },
328
+ });
329
+ }
330
+
331
+ const members: Member[] = [];
332
+ before.forEach((device, index) => {
333
+ const outcome = preflight[index];
334
+ if (!outcome?.selected || !outcome.row) return;
335
+ members.push(
336
+ judgePreflight(
337
+ device,
338
+ outcome,
339
+ resolved,
340
+ controllerName,
341
+ offline,
342
+ nonce,
343
+ ),
344
+ );
345
+ });
346
+ const warnings: Warning[] = [...resolved.warnings];
347
+ if (members.length === 0) {
348
+ warnings.push({
349
+ code: "cmr/empty-selection",
350
+ message: `No CMR device on the controller matches \`${labels}\`. Labels are case-sensitive and RouterOS accepts unknown ones silently.`,
351
+ context: { labels, inventory: before.length },
352
+ });
353
+ }
354
+
355
+ // 2. Bind: a member whose `ids` changed since S1 was validated as another key.
356
+ const middle = await readInventoryBeforeDispatch(controller, signal);
357
+ for (const member of members) {
358
+ if (member.result) continue;
359
+ const now = middle.find((d) => d.id === member.device.id);
360
+ if (!now || now.ids !== member.device.ids) {
361
+ member.result = memberError(
362
+ member,
363
+ resolved,
364
+ controllerName,
365
+ notDispatched(
366
+ "cmr/membership-changed",
367
+ `CMR device ${member.device.id} (${member.device.identity}) changed or left the inventory between validation and dispatch.`,
368
+ { before: member.device, after: now ?? null },
369
+ ),
370
+ );
371
+ }
372
+ }
373
+
374
+ // 3. One write gate, naming the blast radius.
375
+ const runnable = members.filter((m) => !m.result);
376
+ if (write && runnable.length > 0) {
377
+ await confirmCmrWrite(
378
+ request,
379
+ signal,
380
+ resolved,
381
+ controllerName,
382
+ runnable.length,
383
+ members.length - runnable.length,
384
+ );
385
+ }
386
+
387
+ // 4. Dispatch: one call per member, no retry. The prompt and the pool
388
+ // queue can both wait arbitrarily long after S2, so each member is
389
+ // re-bound to the key it was validated on just before its own call.
390
+ const dispatched = await runBoundedPool(
391
+ runnable,
392
+ concurrency,
393
+ async (member) => {
394
+ const unsent = (error: CentrsError) => ({
395
+ member,
396
+ outcome: { selected: true, sent: false, error },
397
+ });
398
+ if (signal.aborted) return unsent(cancelledError());
399
+ const rebound = await rebindBeforeSend(controller, member, signal);
400
+ if (rebound) return unsent(rebound);
401
+ const outcome = await controller.runScript(
402
+ {
403
+ numbers: member.device.id,
404
+ labels,
405
+ script: resolved.command,
406
+ timeoutMs: callTimeoutMs,
407
+ },
408
+ signal,
409
+ );
410
+ return { member, outcome };
411
+ },
412
+ );
413
+ // S3 re-binds every sent result to the key it was validated on. If it
414
+ // cannot be read (or the run was cancelled), sent results are unknown.
415
+ const after =
416
+ dispatched.length > 0 && !signal.aborted
417
+ ? await readInventory(controller).catch(() => undefined)
418
+ : undefined;
419
+ let sentCount = 0;
420
+ for (const { member, outcome } of dispatched) {
421
+ if (outcome.sent) sentCount += 1;
422
+ const now = after?.find((d) => d.id === member.device.id);
423
+ const binding =
424
+ after === undefined
425
+ ? "unverified"
426
+ : now?.ids === member.device.ids
427
+ ? "bound"
428
+ : "drifted";
429
+ member.result = applyMemberBudget(
430
+ judgeDispatch(member, outcome, resolved, controllerName, binding),
431
+ member,
432
+ resolved,
433
+ controllerName,
434
+ );
435
+ }
436
+
437
+ const targets = members.map(
438
+ (member) =>
439
+ member.result ??
440
+ memberError(member, resolved, controllerName, internalUndecided()),
441
+ );
442
+ const summary = summarizeFanout(targets);
443
+ return {
444
+ ok: true,
445
+ data: { summary, targets },
446
+ warnings,
447
+ tips: [],
448
+ meta: {
449
+ target: controllerTargetMeta(resolved),
450
+ via,
451
+ settings: settingsMeta(resolved),
452
+ operation: {
453
+ kind: "cmr",
454
+ concurrency,
455
+ summary,
456
+ request: requestSummary(resolved),
457
+ cmr: {
458
+ labels,
459
+ inventory: before.length,
460
+ selected: members.length,
461
+ dispatched: sentCount,
462
+ },
463
+ },
464
+ },
465
+ };
466
+ } finally {
467
+ await controller.close();
468
+ }
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Preflight and dispatch judgement
473
+ // ---------------------------------------------------------------------------
474
+
475
+ function judgePreflight(
476
+ device: CmrDevice,
477
+ outcome: CmrScriptOutcome,
478
+ resolved: ResolvedExecuteRequest,
479
+ controllerName: string,
480
+ offline: OfflineGateResult | undefined,
481
+ nonce: string,
482
+ ): Member {
483
+ const row = outcome.row as CmrScriptRow;
484
+ const member: Member = {
485
+ device,
486
+ validation: resolved.validate.value
487
+ ? devicePassed(resolved, offline)
488
+ : disabledValidationMeta(),
489
+ };
490
+ if (row.status === "skipped") {
491
+ member.validation = deviceNotReached(
492
+ resolved,
493
+ offline,
494
+ "cmr/client-disconnected",
495
+ );
496
+ member.result = memberError(
497
+ member,
498
+ resolved,
499
+ controllerName,
500
+ disconnectedError(device, row),
501
+ );
502
+ return member;
503
+ }
504
+ const incomplete = (why: string) => {
505
+ member.validation = deviceNotReached(
506
+ resolved,
507
+ offline,
508
+ "cmr/validation-incomplete",
509
+ );
510
+ member.result = memberError(
511
+ member,
512
+ resolved,
513
+ controllerName,
514
+ notDispatched(
515
+ "cmr/validation-incomplete",
516
+ `The validation result from CMR device ${device.identity} was incomplete (${why}), so the command was not sent to it.`,
517
+ { id: device.id, status: row.status, output: row.output },
518
+ ),
519
+ );
520
+ return member;
521
+ };
522
+ if (!FINAL_STATUSES.has(row.status))
523
+ return incomplete(
524
+ outcome.error
525
+ ? `${outcome.error.code}, last status ${row.status}`
526
+ : `last status ${row.status}`,
527
+ );
528
+ if (row.status !== "success")
529
+ return incomplete(`status ${row.status}: ${row.output.trim()}`);
530
+ const text = row.output.replaceAll("\r\n", "\n");
531
+ if (TRUNCATION_MARKER.test(text)) return incomplete("output clipped by CMR");
532
+ const lines = text.replace(/\n+$/, "").split("\n");
533
+ if (lines.at(-1) !== nonce) return incomplete("completion marker missing");
534
+ if (!resolved.validate.value) return member;
535
+ const parse = lines.slice(0, -1).join("\n").trim();
536
+ if (parse.length === 0) return incomplete("empty `:parse` return");
537
+ try {
538
+ classifyParseResult(parse, resolved.command, "cmr run-script");
539
+ assertNotCmrDashboardIl(parse, resolved.command);
540
+ } catch (error) {
541
+ const rejected = error as CentrsError;
542
+ member.validation = deviceRejected(resolved, offline, rejected);
543
+ member.result = memberError(member, resolved, controllerName, rejected);
544
+ return member;
545
+ }
546
+ // Positive evidence: every accepted `:parse` return seen (structured,
547
+ // console spacing, multi-statement, blocks, `:if`/`:foreach`; 7.24.5 and
548
+ // 7.26beta1) starts `(evl `, after a relative menu change if the command
549
+ // opens with one (7.26beta1). A comment-only script returns `/`.
550
+ if (!ACCEPTED_IL.test(parse)) return incomplete("`:parse` return is not IL");
551
+ return member;
552
+ }
553
+
554
+ function judgeDispatch(
555
+ member: Member,
556
+ outcome: CmrScriptOutcome,
557
+ resolved: ResolvedExecuteRequest,
558
+ controllerName: string,
559
+ binding: "bound" | "drifted" | "unverified",
560
+ ): ExecuteEnvelope {
561
+ const device = member.device;
562
+ if (!outcome.selected) {
563
+ return memberError(
564
+ member,
565
+ resolved,
566
+ controllerName,
567
+ notDispatched(
568
+ "cmr/membership-changed",
569
+ `CMR device ${device.identity} no longer matched the labels at dispatch, so the command was not sent to it.`,
570
+ { id: device.id },
571
+ ),
572
+ );
573
+ }
574
+ const row = outcome.row;
575
+ if (!outcome.sent && !(row && FINAL_STATUSES.has(row.status))) {
576
+ return memberError(
577
+ member,
578
+ resolved,
579
+ controllerName,
580
+ outcome.error ?? cancelledError(),
581
+ );
582
+ }
583
+ // CMR itself declined to send: nothing ran, whichever client this was.
584
+ if (row?.status === "skipped") {
585
+ return memberError(
586
+ member,
587
+ resolved,
588
+ controllerName,
589
+ disconnectedError(device, row),
590
+ );
591
+ }
592
+ // A sent call whose member cannot be re-bound after dispatch (its `ids`
593
+ // changed, or S3 was not read) has no attributable result.
594
+ if (binding !== "bound") {
595
+ return memberError(
596
+ member,
597
+ resolved,
598
+ controllerName,
599
+ unknownOutcome(
600
+ device,
601
+ undefined,
602
+ row,
603
+ binding === "drifted"
604
+ ? "its `ids` changed during the call"
605
+ : "the controller inventory was not re-read (cancelled or unreadable) to confirm which client answered",
606
+ ),
607
+ );
608
+ }
609
+ if (!row || !FINAL_STATUSES.has(row.status)) {
610
+ return memberError(
611
+ member,
612
+ resolved,
613
+ controllerName,
614
+ unknownOutcome(device, outcome.error, row),
615
+ );
616
+ }
617
+ if (row.status === "failed") {
618
+ if (TIMEOUT_OUTPUT.test(row.output.trim())) {
619
+ return memberError(
620
+ member,
621
+ resolved,
622
+ controllerName,
623
+ unknownOutcome(device, undefined, row),
624
+ );
625
+ }
626
+ // The diagnostic follows whatever the script printed before it.
627
+ const denied = POLICY_DENIED.exec(row.output.replaceAll("\r\n", "\n"));
628
+ if (denied) {
629
+ return memberError(
630
+ member,
631
+ resolved,
632
+ controllerName,
633
+ new CentrsError({
634
+ code: "cmr/policy-denied",
635
+ summary: `${device.identity} refused a statement: CMR runs scripts with only the read, write, test and reboot policies (\`${denied[0].trim()}\`). Any statements before it already ran.`,
636
+ remediation:
637
+ "A statement that needs another policy (`/user`, `/tool/sniffer`) cannot run through CMR. Check what the earlier statements changed (the output is in `context.output`), then run only the refused statement and the ones after it directly on the device, e.g. `centrs execute <device> -- '<command>'`.",
638
+ context: {
639
+ id: device.id,
640
+ identity: device.identity,
641
+ output: row.output,
642
+ },
643
+ }),
644
+ );
645
+ }
646
+ return memberError(
647
+ member,
648
+ resolved,
649
+ controllerName,
650
+ mapRouterOsError(row.output.trim(), {
651
+ transport:
652
+ resolved.via.value === "rest-api" ? "rest-api" : "native-api",
653
+ context: { cmr: { id: device.id, identity: device.identity } },
654
+ }),
655
+ );
656
+ }
657
+ const warnings: Warning[] = [];
658
+ const clipped = TRUNCATION_MARKER.exec(row.output);
659
+ if (clipped) {
660
+ warnings.push({
661
+ code: "cmr/output-truncated",
662
+ message: `CMR clipped the output from ${device.identity}: ${clipped[1]} more bytes were not returned.`,
663
+ context: { id: device.id, missingBytes: Number(clipped[1]) },
664
+ });
665
+ }
666
+ return {
667
+ ok: true,
668
+ data: { ret: row.output },
669
+ warnings,
670
+ tips: [],
671
+ meta: {
672
+ ...memberMeta(member, resolved, controllerName),
673
+ ...(clipped
674
+ ? {
675
+ truncated: {
676
+ truncated: true,
677
+ reason: "CMR run-script output cap (~60 KiB)",
678
+ serializedBytes: row.output.length + Number(clipped[1]),
679
+ },
680
+ }
681
+ : {}),
682
+ },
683
+ };
684
+ }
685
+
686
+ /**
687
+ * `--max-results` per member, as plain execute fan-out applies it per target:
688
+ * the member's rendered envelope in the requested format, in UTF-8 bytes. The
689
+ * command already ran, so the error says so rather than implying it did not.
690
+ */
691
+ function applyMemberBudget(
692
+ envelope: ExecuteEnvelope,
693
+ member: Member,
694
+ resolved: ResolvedExecuteRequest,
695
+ controllerName: string,
696
+ ): ExecuteEnvelope {
697
+ const budget = resolved.maxResultsBytes?.value;
698
+ if (!budget || !envelope.ok) return envelope;
699
+ const format = resolved.format.value;
700
+ const requiredBytes = new TextEncoder().encode(
701
+ renderExecuteEnvelope(envelope, format, {
702
+ verbose: format === "text" && resolved.verbose,
703
+ }),
704
+ ).length;
705
+ if (requiredBytes <= budget) return envelope;
706
+ const device = member.device;
707
+ return memberError(
708
+ member,
709
+ resolved,
710
+ controllerName,
711
+ new CentrsError({
712
+ code: "input/max-results-exceeded",
713
+ summary: `The command ran on ${device.identity}, but its result (${requiredBytes} bytes) exceeded the requested ${budget}-byte budget, so it is not returned.`,
714
+ remediation:
715
+ "Increase `--max-results` or narrow the command's output. The command already ran on this client; check its state before re-running anything that is not idempotent.",
716
+ context: {
717
+ id: device.id,
718
+ identity: device.identity,
719
+ requiredBytes,
720
+ maxResultsBytes: budget,
721
+ command: resolved.command,
722
+ dispatched: true,
723
+ },
724
+ }),
725
+ );
726
+ }
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // Errors
730
+ // ---------------------------------------------------------------------------
731
+
732
+ function notDispatched(
733
+ code: "cmr/membership-changed" | "cmr/validation-incomplete",
734
+ summary: string,
735
+ context: Record<string, unknown>,
736
+ ): CentrsError {
737
+ return new CentrsError({
738
+ code,
739
+ summary,
740
+ remediation:
741
+ code === "cmr/membership-changed"
742
+ ? "Re-run to validate the current selection; a member is only run with the validation it passed."
743
+ : "The command was not sent to this client. Check the controller and client are responsive, keep the command under ~60 KiB of `:parse` output, and retry.",
744
+ context,
745
+ });
746
+ }
747
+
748
+ function disconnectedError(device: CmrDevice, row: CmrScriptRow): CentrsError {
749
+ return new CentrsError({
750
+ code: "cmr/client-disconnected",
751
+ summary: `CMR skipped ${device.identity}: ${row.output.trim() || "skipped"}. The command was not sent to it.`,
752
+ remediation:
753
+ "Check the client's `/cmr/client` status (`paired,connected`) and its route to the controller, then retry.",
754
+ context: { id: device.id, identity: device.identity, output: row.output },
755
+ });
756
+ }
757
+
758
+ function unknownOutcome(
759
+ device: CmrDevice,
760
+ cause: CentrsError | undefined,
761
+ row: CmrScriptRow | undefined,
762
+ why?: string,
763
+ ): CentrsError {
764
+ const reason =
765
+ why ??
766
+ (row && row.status === "failed"
767
+ ? `CMR reported \`${row.output.trim()}\``
768
+ : cause
769
+ ? `the call ended with ${cause.code}`
770
+ : "the call ended without a final status");
771
+ return new CentrsError({
772
+ code: "cmr/outcome-unknown",
773
+ summary: `The command was sent to ${device.identity}, but its outcome is unknown: ${reason}. It may still be running.`,
774
+ remediation:
775
+ "CMR timeouts and cancellation do not stop a client script. Check the client's state before re-running anything that is not idempotent; centrs never retries it.",
776
+ context: {
777
+ id: device.id,
778
+ identity: device.identity,
779
+ ...(row ? { lastStatus: row.status, output: row.output } : {}),
780
+ ...(cause ? { cause: cause.code } : {}),
781
+ },
782
+ ...(cause ? { cause } : {}),
783
+ });
784
+ }
785
+
786
+ function cancelledError(cause?: unknown): CentrsError {
787
+ return new CentrsError({
788
+ code: "cmr/cancelled",
789
+ summary:
790
+ "The operation was cancelled before the command was sent to this CMR client.",
791
+ remediation: "Nothing ran on this client. Re-run when ready.",
792
+ ...(cause ? { cause } : {}),
793
+ });
794
+ }
795
+
796
+ function internalUndecided(): CentrsError {
797
+ return new CentrsError({
798
+ code: "internal/unhandled",
799
+ summary: "A CMR member finished without a recorded result.",
800
+ remediation:
801
+ "This is a centrs bug; report it with the command and labels used.",
802
+ });
803
+ }
804
+
805
+ function isNoDevicesSelected(error: unknown): boolean {
806
+ if (!(error instanceof CentrsError)) return false;
807
+ const detail = error.context?.["detail"];
808
+ return NO_DEVICES_SELECTED.test(
809
+ `${error.summary} ${typeof detail === "string" ? detail : ""}`,
810
+ );
811
+ }
812
+
813
+ /**
814
+ * The per-member check before its call: `undefined` when the row still carries
815
+ * the `.id` + `ids` the member was validated on, otherwise why it is not sent.
816
+ * The original member set is fixed; a replacement row is never adopted, even
817
+ * when it matches the labels. A host-side read is not atomic with the call:
818
+ * a re-key inside that gap is still caught, after the fact, by S3.
819
+ */
820
+ async function rebindBeforeSend(
821
+ controller: CmrController,
822
+ member: Member,
823
+ signal: AbortSignal,
824
+ ): Promise<CentrsError | undefined> {
825
+ const device = member.device;
826
+ let current: CmrDevice[];
827
+ try {
828
+ current = await readInventoryBeforeDispatch(controller, signal);
829
+ } catch (error) {
830
+ const cause = asCmrError(error);
831
+ return new CentrsError({
832
+ code: cause.code,
833
+ summary: `The CMR inventory could not be re-read before sending to ${device.identity}, so the command was not sent to it: ${cause.summary}`,
834
+ remediation:
835
+ cause.remediation ??
836
+ "Check the controller is reachable and responsive, then re-run.",
837
+ context: { id: device.id, identity: device.identity, dispatched: false },
838
+ cause,
839
+ });
840
+ }
841
+ const now = current.find((d) => d.id === device.id);
842
+ if (now && now.ids === device.ids) return undefined;
843
+ return notDispatched(
844
+ "cmr/membership-changed",
845
+ `CMR device ${device.id} (${device.identity}) changed or left the inventory before its command was sent.`,
846
+ { before: device, after: now ?? null },
847
+ );
848
+ }
849
+
850
+ /**
851
+ * S1/S2: nothing has been sent yet, so a caller abort is `cmr/cancelled`
852
+ * whether the read resolved or the adapter rejected because of it.
853
+ */
854
+ async function readInventoryBeforeDispatch(
855
+ controller: CmrController,
856
+ signal: AbortSignal,
857
+ ): Promise<CmrDevice[]> {
858
+ if (signal.aborted) throw cancelledError();
859
+ let devices: CmrDevice[];
860
+ try {
861
+ devices = await readInventory(controller);
862
+ } catch (error) {
863
+ if (signal.aborted) throw cancelledError(error);
864
+ throw error;
865
+ }
866
+ if (signal.aborted) throw cancelledError();
867
+ return devices;
868
+ }
869
+
870
+ async function readInventory(controller: CmrController): Promise<CmrDevice[]> {
871
+ try {
872
+ return await controller.devices();
873
+ } catch (error) {
874
+ const detail =
875
+ error instanceof CentrsError ? error.context?.["detail"] : undefined;
876
+ if (typeof detail === "string" && MISSING_MENU.test(detail)) {
877
+ throw await noCmrError(controller, detail, error);
878
+ }
879
+ throw error;
880
+ }
881
+ }
882
+
883
+ /** CMR first shipped in 7.26beta1 (manual; the quickchr CMR lab). */
884
+ const CMR_CONTROLLER: CapabilityRequirement = {
885
+ capability: "cmr-controller",
886
+ summary: "be a CMR controller (`/cmr` first shipped in 7.26beta1, a beta)",
887
+ supported: [{ min: "7.26beta1" }],
888
+ remediation:
889
+ "Upgrade the controller to a RouterOS build with CMR (7.26beta1 or later), install the optional `cmr` package, and run `/cmr set enabled=yes`.",
890
+ };
891
+
892
+ /**
893
+ * A missing `/cmr/device` has three causes, told apart only on this failure
894
+ * path: RouterOS older than CMR (`routeros/version-unsupported`), or a 7.26+
895
+ * router without the `cmr` package, possibly a CMR client.
896
+ */
897
+ async function noCmrError(
898
+ controller: CmrController,
899
+ detail: string,
900
+ cause: unknown,
901
+ ): Promise<CentrsError> {
902
+ const found = (await controller.absence?.().catch(() => undefined)) ?? {};
903
+ if (found.version !== undefined) {
904
+ try {
905
+ assertCapability(CMR_CONTROLLER, {
906
+ version: found.version,
907
+ source: "live",
908
+ });
909
+ } catch (error) {
910
+ return error as CentrsError;
911
+ }
912
+ }
913
+ const client = found.controllerIdentity
914
+ ? ` It is a CMR client of ${found.controllerIdentity}${found.controllerAddress ? ` (${found.controllerAddress})` : ""}; point \`--cmr\` at that controller.`
915
+ : "";
916
+ return new CentrsError({
917
+ code: "cmr/not-a-controller",
918
+ summary:
919
+ found.version !== undefined
920
+ ? `RouterOS ${found.version} supports CMR, but the target has no \`/cmr/device\`: the optional \`cmr\` package is not installed.${client}`
921
+ : "The target did not answer `/cmr/device`; it is not a CMR controller.",
922
+ remediation:
923
+ "Point `--cmr` at the router running the optional `cmr` package (RouterOS 7.26beta1+) with `/cmr set enabled=yes`, or install that package on this router.",
924
+ context: {
925
+ detail,
926
+ ...(found.version !== undefined ? { version: found.version } : {}),
927
+ ...(found.controllerIdentity
928
+ ? {
929
+ controllerIdentity: found.controllerIdentity,
930
+ controllerAddress: found.controllerAddress,
931
+ }
932
+ : {}),
933
+ },
934
+ cause,
935
+ });
936
+ }
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // Envelope helpers
940
+ // ---------------------------------------------------------------------------
941
+
942
+ function memberError(
943
+ member: Member,
944
+ resolved: ResolvedExecuteRequest,
945
+ controllerName: string,
946
+ error: CentrsError,
947
+ ): ExecuteEnvelope {
948
+ return {
949
+ ok: false,
950
+ error: serializeCentrsError(error),
951
+ warnings: [],
952
+ tips: [],
953
+ meta: memberMeta(member, resolved, controllerName),
954
+ };
955
+ }
956
+
957
+ function memberMeta(
958
+ member: Member,
959
+ resolved: ResolvedExecuteRequest,
960
+ controllerName: string,
961
+ ): ExecuteEnvelope["meta"] {
962
+ const device = member.device;
963
+ const target: EnvelopeTargetMeta = {
964
+ input: device.identity,
965
+ identity: device.identity,
966
+ cmr: {
967
+ controller: controllerName,
968
+ id: device.id,
969
+ deviceKey: device.ids.split("/")[0] || undefined,
970
+ address: device.address,
971
+ },
972
+ };
973
+ return {
974
+ target,
975
+ via: resolved.via.value,
976
+ settings: settingsMeta(resolved),
977
+ validation: member.validation,
978
+ operation: {
979
+ kind: "execute",
980
+ objectCount: 1,
981
+ request: requestSummary(resolved),
982
+ auth: {
983
+ username: resolved.auth.username,
984
+ passwordProvided: resolved.auth.passwordProvided,
985
+ },
986
+ },
987
+ };
988
+ }
989
+
990
+ function devicePassed(
991
+ resolved: ResolvedExecuteRequest,
992
+ offline: OfflineGateResult | undefined,
993
+ ): EnvelopeValidationMeta {
994
+ return {
995
+ enabled: true,
996
+ source: CMR_VALIDATION_SOURCE,
997
+ result: "passed",
998
+ syntax: true,
999
+ semantic:
1000
+ resolved.canonical.mode === "structured" ? true : "not-applicable",
1001
+ stages: passedStages(
1002
+ offline ?? { verdict: "pass", warnings: [] },
1003
+ CMR_VALIDATION_SOURCE,
1004
+ ),
1005
+ };
1006
+ }
1007
+
1008
+ function deviceRejected(
1009
+ resolved: ResolvedExecuteRequest,
1010
+ offline: OfflineGateResult | undefined,
1011
+ error: CentrsError,
1012
+ ): EnvelopeValidationMeta {
1013
+ const structured = resolved.canonical.mode === "structured";
1014
+ const nameRejected =
1015
+ structured && error.code === "validation/unknown-attribute";
1016
+ return {
1017
+ enabled: true,
1018
+ source: CMR_VALIDATION_SOURCE,
1019
+ result: "failed",
1020
+ ...(nameRejected
1021
+ ? { syntax: true, semantic: false }
1022
+ : {
1023
+ syntax: false,
1024
+ ...(structured ? {} : { semantic: "not-applicable" as const }),
1025
+ }),
1026
+ stages: [
1027
+ offlineStagePassed(offline?.verdict),
1028
+ { stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
1029
+ ],
1030
+ };
1031
+ }
1032
+
1033
+ function deviceNotReached(
1034
+ resolved: ResolvedExecuteRequest,
1035
+ offline: OfflineGateResult | undefined,
1036
+ code: string,
1037
+ ): EnvelopeValidationMeta {
1038
+ if (!resolved.validate.value) return disabledValidationMeta();
1039
+ return {
1040
+ enabled: true,
1041
+ source: CMR_VALIDATION_SOURCE,
1042
+ stages: [
1043
+ offlineStagePassed(offline?.verdict),
1044
+ {
1045
+ stage: "device",
1046
+ source: CMR_VALIDATION_SOURCE,
1047
+ result: "skipped",
1048
+ reason: notReachedReason(code),
1049
+ },
1050
+ ],
1051
+ };
1052
+ }
1053
+
1054
+ function controllerTargetMeta(
1055
+ resolved: ResolvedExecuteRequest,
1056
+ ): EnvelopeTargetMeta {
1057
+ const target = resolved.target;
1058
+ return {
1059
+ input: target.input,
1060
+ host: target.host,
1061
+ port: target.port,
1062
+ baseUrl: target.baseUrl,
1063
+ identity: target.identity,
1064
+ recordIndex: target.recordIndex,
1065
+ source: toCoreSource(target.source),
1066
+ };
1067
+ }
1068
+
1069
+ function settingsMeta(resolved: ResolvedExecuteRequest): CommonSettingsMeta {
1070
+ return {
1071
+ via: toCoreSource(resolved.via.source),
1072
+ host: toCoreSource(resolved.target.hostSource),
1073
+ port: toCoreSource(resolved.target.portSource),
1074
+ timeoutMs: toCoreSource(resolved.timeoutMs.source),
1075
+ format: toCoreSource(resolved.format.source),
1076
+ validate: toCoreSource(resolved.validate.source),
1077
+ username: resolved.auth.usernameSource
1078
+ ? toCoreSource(resolved.auth.usernameSource)
1079
+ : undefined,
1080
+ password: resolved.auth.passwordSource
1081
+ ? toCoreSource(resolved.auth.passwordSource)
1082
+ : undefined,
1083
+ };
1084
+ }
1085
+
1086
+ function requestSummary(
1087
+ resolved: ResolvedExecuteRequest,
1088
+ ): ExecuteRequestSummary {
1089
+ return {
1090
+ command: resolved.command,
1091
+ canonical: resolved.canonical,
1092
+ write: isWriteShaped(resolved.canonical),
1093
+ yes: resolved.yes,
1094
+ validate: resolved.validate.value,
1095
+ verbose: resolved.verbose,
1096
+ timeoutMs: resolved.timeoutMs.value,
1097
+ format: resolved.format.value,
1098
+ maxResultsBytes: resolved.maxResultsBytes?.value,
1099
+ };
1100
+ }
1101
+
1102
+ async function confirmCmrWrite(
1103
+ request: ExecuteCmrRequest,
1104
+ signal: AbortSignal,
1105
+ resolved: ResolvedExecuteRequest,
1106
+ controllerName: string,
1107
+ runnable: number,
1108
+ excluded: number,
1109
+ ): Promise<void> {
1110
+ if (resolved.yes) return;
1111
+ const scope = `${runnable} CMR client(s) via controller ${controllerName}${excluded > 0 ? ` (${excluded} excluded)` : ""}`;
1112
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
1113
+ // The prompt reads stdin and cannot see `signal`, so race it: a cancel at
1114
+ // the prompt returns here and the dispatch loop reports every member
1115
+ // `cmr/cancelled` without sending anything.
1116
+ const cancelled = new Promise<false>((resolve) => {
1117
+ if (signal.aborted) resolve(false);
1118
+ signal.addEventListener("abort", () => resolve(false), { once: true });
1119
+ });
1120
+ const confirmed = await Promise.race([
1121
+ (request.confirm ?? promptForWriteConfirmation)(
1122
+ `Run write-shaped \`${resolved.command}\` on ${scope}? Type yes to continue: `,
1123
+ ),
1124
+ cancelled,
1125
+ ]);
1126
+ if (confirmed || signal.aborted) return;
1127
+ }
1128
+ throw new CentrsError({
1129
+ code: "usage/confirmation-required",
1130
+ summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation.`,
1131
+ remediation:
1132
+ "Pass `--yes` after reviewing the selection, or answer `yes` at the TTY prompt. Clients that failed validation are never sent the command.",
1133
+ context: {
1134
+ command: resolved.command,
1135
+ routers: runnable,
1136
+ excluded,
1137
+ controller: controllerName,
1138
+ },
1139
+ });
1140
+ }
1141
+
1142
+ function defaultNonce(): string {
1143
+ return `centrs-cmr-${crypto.randomUUID().slice(0, 8)}`;
1144
+ }
1145
+
1146
+ // ---------------------------------------------------------------------------
1147
+ // Production controller: one protocol adapter to the controller
1148
+ // ---------------------------------------------------------------------------
1149
+
1150
+ function adapterController(
1151
+ resolved: ResolvedExecuteRequest,
1152
+ signal: AbortSignal,
1153
+ ): CmrController {
1154
+ const via = resolved.via.value;
1155
+ const config = (timeoutMs: number, signal?: AbortSignal) => ({
1156
+ protocol: via,
1157
+ host: resolved.target.host,
1158
+ port: resolved.target.port,
1159
+ tls: resolved.target.tls,
1160
+ baseUrl: resolved.target.baseUrl,
1161
+ username: resolved.auth.username,
1162
+ password: resolved.auth.password,
1163
+ timeoutMs,
1164
+ insecure: resolved.insecure.value,
1165
+ signal,
1166
+ });
1167
+ // Native: one session for every call (concurrent tagged commands).
1168
+ // REST: an adapter per call, so each carries its own deadline signal.
1169
+ const shared: ProtocolAdapter = createProtocolAdapter(
1170
+ config(resolved.timeoutMs.value, signal),
1171
+ );
1172
+ return {
1173
+ async devices() {
1174
+ const result = await shared.apiRequest({
1175
+ verb: "print",
1176
+ path: "/cmr/device",
1177
+ });
1178
+ const rows = Array.isArray(result.data)
1179
+ ? (result.data as Record<string, string>[])
1180
+ : [];
1181
+ return rows.map((row) => ({
1182
+ id: row[".id"] ?? "",
1183
+ ids: row["ids"] ?? "",
1184
+ identity: row["identity"] ?? "",
1185
+ address: row["address"] || undefined,
1186
+ }));
1187
+ },
1188
+ runScript(call, signal) {
1189
+ return via === "native-api"
1190
+ ? nativeRunScript(shared, call, signal)
1191
+ : restRunScript(
1192
+ (ms, s) => createProtocolAdapter(config(ms, s)),
1193
+ call,
1194
+ signal,
1195
+ );
1196
+ },
1197
+ async absence() {
1198
+ const version = await probeDeviceVersion(shared).catch(() => undefined);
1199
+ const client = (await shared
1200
+ .getSingleton("/cmr/client")
1201
+ .catch(() => undefined)) as Record<string, string> | undefined;
1202
+ return {
1203
+ version: version?.version,
1204
+ controllerIdentity: client?.["controller-identity"] || undefined,
1205
+ controllerAddress: client?.["controller-address"] || undefined,
1206
+ };
1207
+ },
1208
+ close: () => shared.close(),
1209
+ };
1210
+ }
1211
+
1212
+ function runScriptAttributes(call: CmrScriptCall): Record<string, string> {
1213
+ return {
1214
+ numbers: call.numbers,
1215
+ labels: call.labels,
1216
+ script: call.script,
1217
+ timeout: `${Math.max(1, Math.ceil(call.timeoutMs / 1000))}s`,
1218
+ ...RUN_SCRIPT_PINNED,
1219
+ };
1220
+ }
1221
+
1222
+ function toRow(attributes: Record<string, string>): CmrScriptRow {
1223
+ return {
1224
+ device: attributes["device"] ?? "",
1225
+ status: attributes["status"] ?? "",
1226
+ output: attributes["output"] ?? "",
1227
+ section: Number(attributes[".section"] ?? 0),
1228
+ };
1229
+ }
1230
+
1231
+ function newer(
1232
+ current: CmrScriptRow | undefined,
1233
+ next: CmrScriptRow,
1234
+ ): CmrScriptRow {
1235
+ return current && current.section > next.section ? current : next;
1236
+ }
1237
+
1238
+ function deadlineSignal(signal: AbortSignal, call: CmrScriptCall): AbortSignal {
1239
+ return AbortSignal.any([
1240
+ signal,
1241
+ AbortSignal.timeout(call.timeoutMs + CMR_CALL_GRACE_MS),
1242
+ ]);
1243
+ }
1244
+
1245
+ async function nativeRunScript(
1246
+ adapter: ProtocolAdapter,
1247
+ call: CmrScriptCall,
1248
+ outer: AbortSignal,
1249
+ ): Promise<CmrScriptOutcome> {
1250
+ const signal = deadlineSignal(outer, call);
1251
+ let sent = false;
1252
+ let row: CmrScriptRow | undefined;
1253
+ try {
1254
+ for await (const reply of adapter.stream(
1255
+ {
1256
+ verb: "run",
1257
+ path: CMR_RUN_SCRIPT_PATH,
1258
+ attributes: runScriptAttributes(call),
1259
+ },
1260
+ {
1261
+ signal,
1262
+ cancelGraceMs: CMR_CALL_GRACE_MS,
1263
+ onListening: () => {
1264
+ sent = true;
1265
+ },
1266
+ },
1267
+ )) {
1268
+ if (reply.type === "re") row = newer(row, toRow(reply.attributes));
1269
+ }
1270
+ } catch (error) {
1271
+ if (isNoDevicesSelected(error)) return { selected: false, sent: false };
1272
+ return { selected: true, sent, row, error: asCmrError(error) };
1273
+ }
1274
+ if (signal.aborted && !(row && FINAL_STATUSES.has(row.status))) {
1275
+ return { selected: true, sent, row, error: abortError(outer, call) };
1276
+ }
1277
+ return { selected: true, sent, row };
1278
+ }
1279
+
1280
+ async function restRunScript(
1281
+ adapterFor: (timeoutMs: number, signal: AbortSignal) => ProtocolAdapter,
1282
+ call: CmrScriptCall,
1283
+ outer: AbortSignal,
1284
+ ): Promise<CmrScriptOutcome> {
1285
+ if (outer.aborted)
1286
+ return { selected: true, sent: false, error: cancelledError() };
1287
+ const signal = deadlineSignal(outer, call);
1288
+ const adapter = adapterFor(call.timeoutMs + CMR_CALL_GRACE_MS, signal);
1289
+ try {
1290
+ const result = await adapter.apiRequest({
1291
+ verb: "run",
1292
+ path: CMR_RUN_SCRIPT_PATH,
1293
+ attributes: runScriptAttributes(call),
1294
+ });
1295
+ let row: CmrScriptRow | undefined;
1296
+ for (const attributes of Array.isArray(result.data)
1297
+ ? (result.data as Record<string, string>[])
1298
+ : []) {
1299
+ row = newer(row, toRow(attributes));
1300
+ }
1301
+ return { selected: true, sent: true, row };
1302
+ } catch (error) {
1303
+ if (isNoDevicesSelected(error)) return { selected: false, sent: false };
1304
+ const mapped = signal.aborted ? abortError(outer, call) : asCmrError(error);
1305
+ // REST cannot say whether a failed request reached RouterOS, except when
1306
+ // the connection itself never opened.
1307
+ const neverOpened = NOT_SENT_TRANSPORT_CODES.includes(mapped.code);
1308
+ return { selected: true, sent: !neverOpened, error: mapped };
1309
+ } finally {
1310
+ await adapter.close();
1311
+ }
1312
+ }
1313
+
1314
+ function abortError(outer: AbortSignal, call: CmrScriptCall): CentrsError {
1315
+ if (outer.aborted) {
1316
+ return new CentrsError({
1317
+ code: "cmr/cancelled",
1318
+ summary: "The CMR call was cancelled.",
1319
+ remediation:
1320
+ "Cancellation does not stop a client script that already started.",
1321
+ });
1322
+ }
1323
+ return new CentrsError({
1324
+ code: "transport/timeout",
1325
+ summary: `The controller gave no final status within ${call.timeoutMs + CMR_CALL_GRACE_MS} ms.`,
1326
+ remediation: "Raise `--timeout` (CMR caps one call at ~60 s).",
1327
+ context: { timeoutMs: call.timeoutMs, graceMs: CMR_CALL_GRACE_MS },
1328
+ });
1329
+ }
1330
+
1331
+ function asCmrError(error: unknown): CentrsError {
1332
+ return error instanceof CentrsError
1333
+ ? error
1334
+ : new CentrsError({
1335
+ code: "internal/unhandled",
1336
+ summary: "A CMR controller call failed unexpectedly.",
1337
+ remediation:
1338
+ "Re-run with `--format json` to capture the structured cause.",
1339
+ cause: error,
1340
+ });
1341
+ }
1342
+
1343
+ // ---------------------------------------------------------------------------
1344
+ // Rendering
1345
+ // ---------------------------------------------------------------------------
1346
+
1347
+ /** Outer error envelope for a CMR run that failed before any member result. */
1348
+ export function buildExecuteCmrErrorEnvelope(
1349
+ request: Pick<ExecuteRequest, "targetInput" | "via">,
1350
+ error: unknown,
1351
+ ): ExecuteCmrErrorEnvelope {
1352
+ const centrsError = asCmrError(error);
1353
+ const via =
1354
+ request.via === "rest-api" || request.via === "native-api"
1355
+ ? (request.via as RouterOsProtocol)
1356
+ : null;
1357
+ return {
1358
+ ok: false,
1359
+ error: serializeCentrsError(centrsError),
1360
+ warnings: [],
1361
+ tips: [],
1362
+ meta: { target: { input: request.targetInput }, via, settings: {} },
1363
+ };
1364
+ }
1365
+
1366
+ /** Text: a summary line, then each member's status and (indented) output. */
1367
+ export function renderExecuteCmrEnvelope(
1368
+ envelope: ExecuteCmrEnvelope | ExecuteCmrErrorEnvelope,
1369
+ format: "json" | "yaml" | "text",
1370
+ options: { verbose?: boolean } = {},
1371
+ ): string {
1372
+ if (format === "json") return JSON.stringify(envelope, null, 2);
1373
+ if (format === "yaml") return toYaml(envelope);
1374
+ if (!envelope.ok) return formatCentrsErrorText(envelope.error, options);
1375
+ const operation = envelope.meta.operation;
1376
+ const { total, ok, failed } = envelope.data.summary;
1377
+ const controller =
1378
+ envelope.meta.target.identity ??
1379
+ envelope.meta.target.input ??
1380
+ envelope.meta.target.host ??
1381
+ "?";
1382
+ const lines = [
1383
+ terminalSafeLine(
1384
+ `execute --cmr ${operation?.cmr.labels ?? "?"} via ${controller}: ${ok}/${total} ok, ${failed} failed`,
1385
+ ),
1386
+ ];
1387
+ for (const target of envelope.data.targets) {
1388
+ const id = target.meta.target.cmr?.id ?? "-";
1389
+ const label = target.meta.target.identity ?? "(unknown)";
1390
+ if (target.ok) {
1391
+ lines.push(terminalSafeLine(` [${id}] ok ${label}`));
1392
+ const ret = (target.data as { ret?: string }).ret ?? "";
1393
+ for (const line of ret
1394
+ .replaceAll("\r\n", "\n")
1395
+ .replace(/\n+$/, "")
1396
+ .split("\n")) {
1397
+ if (line.length > 0) lines.push(terminalSafeLine(` ${line}`));
1398
+ }
1399
+ } else {
1400
+ lines.push(
1401
+ terminalSafeLine(
1402
+ ` [${id}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
1403
+ ),
1404
+ );
1405
+ if (options.verbose && target.error.remediation) {
1406
+ lines.push(
1407
+ terminalSafeLine(` Fix: ${target.error.remediation}`),
1408
+ );
1409
+ }
1410
+ }
1411
+ for (const warning of target.warnings) {
1412
+ lines.push(
1413
+ terminalSafeLine(
1414
+ ` warning [${warning.code}]: ${warning.message}`,
1415
+ ),
1416
+ );
1417
+ }
1418
+ }
1419
+ for (const warning of envelope.warnings) {
1420
+ lines.push(
1421
+ terminalSafeLine(`warning [${warning.code}]: ${warning.message}`),
1422
+ );
1423
+ }
1424
+ return lines.join("\n");
1425
+ }