@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
@@ -39,9 +39,21 @@
39
39
  * - `freeze-frame-interval=20ms` brings a trivial call from ~1 s to ~25 ms;
40
40
  * `strategy`/`fail-policy` are pinned and `order` is never sent (it adds
41
41
  * `pending` and policy-`skipped` statuses).
42
+ * - The caller's policy is not checked against the script (#456):
43
+ * `run-script` needs only `read` + `api` on the controller login, and a
44
+ * `!write !reboot` login wrote to and rebooted clients, the controller's
45
+ * own row included. The write gate here is the only one; a write run by
46
+ * such a login carries `cmr/caller-policy-not-enforced`.
42
47
  */
43
48
 
44
- import { assertNotCmrDashboardIl, CMR_RUN_SCRIPT_PATH } from "./core/cmr.ts";
49
+ import {
50
+ assertNotCmrDashboardIl,
51
+ CMR_RUN_SCRIPT_PATH,
52
+ CMR_SCRIPT_POLICIES,
53
+ type CmrCallerPolicy,
54
+ cmrCallerPolicyWarning,
55
+ readCmrCallerPolicy,
56
+ } from "./core/cmr.ts";
45
57
  import type {
46
58
  CentrsErrorEnvelope,
47
59
  CentrsSuccessEnvelope,
@@ -55,6 +67,7 @@ import type {
55
67
  import { notReachedReason } from "./core/envelope.ts";
56
68
  import { isWriteShaped } from "./core/execute-command.ts";
57
69
  import {
70
+ buildFanoutEnvelope,
58
71
  NOT_SENT_TRANSPORT_CODES,
59
72
  resolveFanoutConcurrency,
60
73
  runBoundedPool,
@@ -94,7 +107,7 @@ import {
94
107
  type RouterOsProtocol,
95
108
  } from "./protocols/index.ts";
96
109
  import { classifyParseResult } from "./protocols/mac-telnet-console.ts";
97
- import { toCoreSource } from "./resolver/index.ts";
110
+ import { redactTargetInput, toCoreSource } from "./resolver/index.ts";
98
111
 
99
112
  /** The device-stage validator identity for a CMR client. */
100
113
  export const CMR_VALIDATION_SOURCE = ":put [:parse ...] via cmr run-script";
@@ -184,6 +197,8 @@ export interface CmrController {
184
197
  devices(): Promise<CmrDevice[]>;
185
198
  /** Explain a missing `/cmr/device`; each read is best effort. */
186
199
  absence?(): Promise<CmrAbsence>;
200
+ /** The login's own group policy; `undefined` when it cannot be read. */
201
+ callerPolicy?(): Promise<CmrCallerPolicy | undefined>;
187
202
  runScript(
188
203
  call: CmrScriptCall,
189
204
  signal: AbortSignal,
@@ -204,6 +219,8 @@ export interface ExecuteCmrOperationMeta {
204
219
  concurrency: number;
205
220
  summary: FanoutSummary;
206
221
  request: ExecuteRequestSummary;
222
+ /** Hoisted from the members: every member runs under the controller login. */
223
+ auth?: { username?: string; passwordProvided: boolean };
207
224
  cmr: {
208
225
  labels: string;
209
226
  /** `/cmr/device` rows considered. */
@@ -279,6 +296,7 @@ export async function executeCmr(
279
296
  );
280
297
  const nonce = (internals.nonce ?? defaultNonce)();
281
298
 
299
+ const warnings: Warning[] = [...resolved.warnings];
282
300
  try {
283
301
  // 1. Inventory and per-row preflight (membership + client validation).
284
302
  const before = await readInventoryBeforeDispatch(controller, signal);
@@ -332,24 +350,23 @@ export async function executeCmr(
332
350
  before.forEach((device, index) => {
333
351
  const outcome = preflight[index];
334
352
  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
- );
353
+ members.push(judgePreflight(device, outcome, resolved, offline, nonce));
345
354
  });
346
- const warnings: Warning[] = [...resolved.warnings];
347
355
  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
- });
356
+ const message = `No CMR device on the controller matches \`${labels}\`. Labels are case-sensitive and RouterOS accepts unknown ones silently.`;
357
+ const context = { labels, inventory: before.length };
358
+ // #460: RouterOS refuses an empty selection (`no devices selected`);
359
+ // a write here must not exit 0 on a mistyped label.
360
+ if (write) {
361
+ throw new CentrsError({
362
+ code: "cmr/empty-selection",
363
+ summary: `${message} Nothing ran: a write-shaped command must select at least one client.`,
364
+ remediation:
365
+ "Check the labels with `centrs retrieve <controller> /cmr/device` (`labels`, `auto-labels`), then re-run. A read over the same labels succeeds with a warning instead.",
366
+ context: { ...context, write: true },
367
+ });
368
+ }
369
+ warnings.push({ code: "cmr/empty-selection", message, context });
353
370
  }
354
371
 
355
372
  // 2. Bind: a member whose `ids` changed since S1 was validated as another key.
@@ -361,7 +378,6 @@ export async function executeCmr(
361
378
  member.result = memberError(
362
379
  member,
363
380
  resolved,
364
- controllerName,
365
381
  notDispatched(
366
382
  "cmr/membership-changed",
367
383
  `CMR device ${member.device.id} (${member.device.identity}) changed or left the inventory between validation and dispatch.`,
@@ -371,9 +387,14 @@ export async function executeCmr(
371
387
  }
372
388
  }
373
389
 
374
- // 3. One write gate, naming the blast radius.
390
+ // 3. One write gate, naming the blast radius. The login's policy is read
391
+ // once, best effort: a failed read only loses the warning.
375
392
  const runnable = members.filter((m) => !m.result);
376
393
  if (write && runnable.length > 0) {
394
+ const caller = await controller.callerPolicy?.().catch(() => undefined);
395
+ const notEnforced =
396
+ caller && cmrCallerPolicyWarning(caller, CMR_SCRIPT_POLICIES);
397
+ if (notEnforced) warnings.push(notEnforced);
377
398
  await confirmCmrWrite(
378
399
  request,
379
400
  signal,
@@ -381,6 +402,7 @@ export async function executeCmr(
381
402
  controllerName,
382
403
  runnable.length,
383
404
  members.length - runnable.length,
405
+ notEnforced,
384
406
  );
385
407
  }
386
408
 
@@ -427,47 +449,56 @@ export async function executeCmr(
427
449
  ? "bound"
428
450
  : "drifted";
429
451
  member.result = applyMemberBudget(
430
- judgeDispatch(member, outcome, resolved, controllerName, binding),
452
+ judgeDispatch(member, outcome, resolved, binding),
431
453
  member,
432
454
  resolved,
433
- controllerName,
434
455
  );
435
456
  }
436
457
 
437
458
  const targets = members.map(
438
459
  (member) =>
439
- member.result ??
440
- memberError(member, resolved, controllerName, internalUndecided()),
460
+ member.result ?? memberError(member, resolved, internalUndecided()),
441
461
  );
442
462
  const summary = summarizeFanout(targets);
443
- return {
444
- ok: true,
445
- data: { summary, targets },
463
+ return buildFanoutEnvelope<
464
+ unknown,
465
+ ExecuteOperationMeta,
466
+ ExecuteCmrOperationMeta
467
+ >({
468
+ summary,
469
+ targets,
446
470
  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
- },
471
+ settings: settingsMeta(resolved),
472
+ via,
473
+ target: controllerTargetMeta(resolved),
474
+ operation: {
475
+ kind: "cmr",
476
+ concurrency,
477
+ summary,
478
+ request: requestSummary(resolved),
479
+ cmr: {
480
+ labels,
481
+ inventory: before.length,
482
+ selected: members.length,
483
+ dispatched: sentCount,
463
484
  },
464
485
  },
465
- };
486
+ });
487
+ } catch (error) {
488
+ // An outer error keeps the warnings gathered before it, such as
489
+ // `cmr/caller-policy-not-enforced` on a refused confirmation.
490
+ if (error instanceof CentrsError && warnings.length > 0) {
491
+ OUTER_ERROR_WARNINGS.set(error, [...warnings]);
492
+ }
493
+ throw error;
466
494
  } finally {
467
495
  await controller.close();
468
496
  }
469
497
  }
470
498
 
499
+ /** Warnings an outer `executeCmr` error carried; read by {@link buildExecuteCmrErrorEnvelope}. */
500
+ const OUTER_ERROR_WARNINGS = new WeakMap<CentrsError, Warning[]>();
501
+
471
502
  // ---------------------------------------------------------------------------
472
503
  // Preflight and dispatch judgement
473
504
  // ---------------------------------------------------------------------------
@@ -476,7 +507,6 @@ function judgePreflight(
476
507
  device: CmrDevice,
477
508
  outcome: CmrScriptOutcome,
478
509
  resolved: ResolvedExecuteRequest,
479
- controllerName: string,
480
510
  offline: OfflineGateResult | undefined,
481
511
  nonce: string,
482
512
  ): Member {
@@ -496,7 +526,6 @@ function judgePreflight(
496
526
  member.result = memberError(
497
527
  member,
498
528
  resolved,
499
- controllerName,
500
529
  disconnectedError(device, row),
501
530
  );
502
531
  return member;
@@ -510,7 +539,6 @@ function judgePreflight(
510
539
  member.result = memberError(
511
540
  member,
512
541
  resolved,
513
- controllerName,
514
542
  notDispatched(
515
543
  "cmr/validation-incomplete",
516
544
  `The validation result from CMR device ${device.identity} was incomplete (${why}), so the command was not sent to it.`,
@@ -540,7 +568,7 @@ function judgePreflight(
540
568
  } catch (error) {
541
569
  const rejected = error as CentrsError;
542
570
  member.validation = deviceRejected(resolved, offline, rejected);
543
- member.result = memberError(member, resolved, controllerName, rejected);
571
+ member.result = memberError(member, resolved, rejected);
544
572
  return member;
545
573
  }
546
574
  // Positive evidence: every accepted `:parse` return seen (structured,
@@ -555,7 +583,6 @@ function judgeDispatch(
555
583
  member: Member,
556
584
  outcome: CmrScriptOutcome,
557
585
  resolved: ResolvedExecuteRequest,
558
- controllerName: string,
559
586
  binding: "bound" | "drifted" | "unverified",
560
587
  ): ExecuteEnvelope {
561
588
  const device = member.device;
@@ -563,7 +590,6 @@ function judgeDispatch(
563
590
  return memberError(
564
591
  member,
565
592
  resolved,
566
- controllerName,
567
593
  notDispatched(
568
594
  "cmr/membership-changed",
569
595
  `CMR device ${device.identity} no longer matched the labels at dispatch, so the command was not sent to it.`,
@@ -573,21 +599,11 @@ function judgeDispatch(
573
599
  }
574
600
  const row = outcome.row;
575
601
  if (!outcome.sent && !(row && FINAL_STATUSES.has(row.status))) {
576
- return memberError(
577
- member,
578
- resolved,
579
- controllerName,
580
- outcome.error ?? cancelledError(),
581
- );
602
+ return memberError(member, resolved, outcome.error ?? cancelledError());
582
603
  }
583
604
  // CMR itself declined to send: nothing ran, whichever client this was.
584
605
  if (row?.status === "skipped") {
585
- return memberError(
586
- member,
587
- resolved,
588
- controllerName,
589
- disconnectedError(device, row),
590
- );
606
+ return memberError(member, resolved, disconnectedError(device, row));
591
607
  }
592
608
  // A sent call whose member cannot be re-bound after dispatch (its `ids`
593
609
  // changed, or S3 was not read) has no attributable result.
@@ -595,7 +611,6 @@ function judgeDispatch(
595
611
  return memberError(
596
612
  member,
597
613
  resolved,
598
- controllerName,
599
614
  unknownOutcome(
600
615
  device,
601
616
  undefined,
@@ -610,7 +625,6 @@ function judgeDispatch(
610
625
  return memberError(
611
626
  member,
612
627
  resolved,
613
- controllerName,
614
628
  unknownOutcome(device, outcome.error, row),
615
629
  );
616
630
  }
@@ -619,7 +633,6 @@ function judgeDispatch(
619
633
  return memberError(
620
634
  member,
621
635
  resolved,
622
- controllerName,
623
636
  unknownOutcome(device, undefined, row),
624
637
  );
625
638
  }
@@ -629,7 +642,6 @@ function judgeDispatch(
629
642
  return memberError(
630
643
  member,
631
644
  resolved,
632
- controllerName,
633
645
  new CentrsError({
634
646
  code: "cmr/policy-denied",
635
647
  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.`,
@@ -646,7 +658,6 @@ function judgeDispatch(
646
658
  return memberError(
647
659
  member,
648
660
  resolved,
649
- controllerName,
650
661
  mapRouterOsError(row.output.trim(), {
651
662
  transport:
652
663
  resolved.via.value === "rest-api" ? "rest-api" : "native-api",
@@ -669,7 +680,7 @@ function judgeDispatch(
669
680
  warnings,
670
681
  tips: [],
671
682
  meta: {
672
- ...memberMeta(member, resolved, controllerName),
683
+ ...memberMeta(member, resolved),
673
684
  ...(clipped
674
685
  ? {
675
686
  truncated: {
@@ -692,7 +703,6 @@ function applyMemberBudget(
692
703
  envelope: ExecuteEnvelope,
693
704
  member: Member,
694
705
  resolved: ResolvedExecuteRequest,
695
- controllerName: string,
696
706
  ): ExecuteEnvelope {
697
707
  const budget = resolved.maxResultsBytes?.value;
698
708
  if (!budget || !envelope.ok) return envelope;
@@ -707,7 +717,6 @@ function applyMemberBudget(
707
717
  return memberError(
708
718
  member,
709
719
  resolved,
710
- controllerName,
711
720
  new CentrsError({
712
721
  code: "input/max-results-exceeded",
713
722
  summary: `The command ran on ${device.identity}, but its result (${requiredBytes} bytes) exceeded the requested ${budget}-byte budget, so it is not returned.`,
@@ -942,7 +951,6 @@ async function noCmrError(
942
951
  function memberError(
943
952
  member: Member,
944
953
  resolved: ResolvedExecuteRequest,
945
- controllerName: string,
946
954
  error: CentrsError,
947
955
  ): ExecuteEnvelope {
948
956
  return {
@@ -950,21 +958,19 @@ function memberError(
950
958
  error: serializeCentrsError(error),
951
959
  warnings: [],
952
960
  tips: [],
953
- meta: memberMeta(member, resolved, controllerName),
961
+ meta: memberMeta(member, resolved),
954
962
  };
955
963
  }
956
964
 
957
965
  function memberMeta(
958
966
  member: Member,
959
967
  resolved: ResolvedExecuteRequest,
960
- controllerName: string,
961
968
  ): ExecuteEnvelope["meta"] {
962
969
  const device = member.device;
963
970
  const target: EnvelopeTargetMeta = {
964
971
  input: device.identity,
965
972
  identity: device.identity,
966
973
  cmr: {
967
- controller: controllerName,
968
974
  id: device.id,
969
975
  deviceKey: device.ids.split("/")[0] || undefined,
970
976
  address: device.address,
@@ -1106,9 +1112,11 @@ async function confirmCmrWrite(
1106
1112
  controllerName: string,
1107
1113
  runnable: number,
1108
1114
  excluded: number,
1115
+ notEnforced: Warning | undefined,
1109
1116
  ): Promise<void> {
1110
1117
  if (resolved.yes) return;
1111
1118
  const scope = `${runnable} CMR client(s) via controller ${controllerName}${excluded > 0 ? ` (${excluded} excluded)` : ""}`;
1119
+ const policyNote = notEnforced ? ` ${notEnforced.message}` : "";
1112
1120
  if (request.stdinIsTty ?? process.stdin.isTTY) {
1113
1121
  // The prompt reads stdin and cannot see `signal`, so race it: a cancel at
1114
1122
  // the prompt returns here and the dispatch loop reports every member
@@ -1119,7 +1127,7 @@ async function confirmCmrWrite(
1119
1127
  });
1120
1128
  const confirmed = await Promise.race([
1121
1129
  (request.confirm ?? promptForWriteConfirmation)(
1122
- `Run write-shaped \`${resolved.command}\` on ${scope}? Type yes to continue: `,
1130
+ `Run write-shaped \`${resolved.command}\` on ${scope}?${policyNote} Type yes to continue: `,
1123
1131
  ),
1124
1132
  cancelled,
1125
1133
  ]);
@@ -1127,7 +1135,7 @@ async function confirmCmrWrite(
1127
1135
  }
1128
1136
  throw new CentrsError({
1129
1137
  code: "usage/confirmation-required",
1130
- summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation.`,
1138
+ summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation.${policyNote}`,
1131
1139
  remediation:
1132
1140
  "Pass `--yes` after reviewing the selection, or answer `yes` at the TTY prompt. Clients that failed validation are never sent the command.",
1133
1141
  context: {
@@ -1135,6 +1143,7 @@ async function confirmCmrWrite(
1135
1143
  routers: runnable,
1136
1144
  excluded,
1137
1145
  controller: controllerName,
1146
+ ...(notEnforced ? { callerPolicy: notEnforced.context } : {}),
1138
1147
  },
1139
1148
  });
1140
1149
  }
@@ -1205,6 +1214,10 @@ function adapterController(
1205
1214
  controllerAddress: client?.["controller-address"] || undefined,
1206
1215
  };
1207
1216
  },
1217
+ callerPolicy: () =>
1218
+ resolved.auth.username
1219
+ ? readCmrCallerPolicy(shared, resolved.auth.username)
1220
+ : Promise.resolve(undefined),
1208
1221
  close: () => shared.close(),
1209
1222
  };
1210
1223
  }
@@ -1357,9 +1370,13 @@ export function buildExecuteCmrErrorEnvelope(
1357
1370
  return {
1358
1371
  ok: false,
1359
1372
  error: serializeCentrsError(centrsError),
1360
- warnings: [],
1373
+ warnings: OUTER_ERROR_WARNINGS.get(centrsError) ?? [],
1361
1374
  tips: [],
1362
- meta: { target: { input: request.targetInput }, via, settings: {} },
1375
+ meta: {
1376
+ target: { input: redactTargetInput(request.targetInput) },
1377
+ via,
1378
+ settings: {},
1379
+ },
1363
1380
  };
1364
1381
  }
1365
1382
 
@@ -60,9 +60,11 @@ import {
60
60
  } from "./execute.ts";
61
61
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
62
62
  import {
63
+ emptyWriteSelectionError,
63
64
  expandSelection,
64
65
  isDefaultRecordTarget,
65
66
  loadEnvFileDefaults,
67
+ redactTargetInput,
66
68
  type SelectionExpansion,
67
69
  type SelectionMember,
68
70
  type SelectionResolveInput,
@@ -85,6 +87,8 @@ export interface ExecuteFanoutOperationMeta {
85
87
  concurrency: number;
86
88
  summary: FanoutSummary;
87
89
  request: ExecuteRequestSummary;
90
+ /** Hoisted from the targets when every target used the same login (#461). */
91
+ auth?: { username?: string; passwordProvided: boolean };
88
92
  selection: ExecuteSelectionSummary;
89
93
  }
90
94
 
@@ -125,7 +129,7 @@ function summarizeSelection(
125
129
  where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
126
130
  all: selection.all,
127
131
  default: selection.default,
128
- positionals: selection.positionals,
132
+ positionals: selection.positionals.map((value) => redactTargetInput(value)),
129
133
  quickchr: selection.quickchr,
130
134
  };
131
135
  }
@@ -225,6 +229,12 @@ export async function executeFanout(
225
229
  const concurrency = resolveFanoutConcurrency(options.concurrency, via);
226
230
 
227
231
  if (expansion.empty) {
232
+ if (global.summary.write) {
233
+ throw emptyWriteSelectionError(
234
+ expansion,
235
+ `\`${global.summary.command}\``,
236
+ );
237
+ }
228
238
  return executeFanoutEnvelope({
229
239
  selection: selectionSummary,
230
240
  concurrency,
package/src/execute.ts CHANGED
@@ -4,6 +4,13 @@ import {
4
4
  cmrRunScriptWarnings,
5
5
  cmrScriptWarnings,
6
6
  } from "./core/cmr.ts";
7
+ import {
8
+ type CmrFleetRead,
9
+ cmrFleetCommand,
10
+ cmrFleetGate,
11
+ cmrFleetReadable,
12
+ readCmrFleet,
13
+ } from "./core/cmr-fleet.ts";
7
14
  import type {
8
15
  CentrsEnvelope,
9
16
  CentrsErrorEnvelope,
@@ -65,6 +72,7 @@ import {
65
72
  type ResolvedAuth,
66
73
  type ResolvedSetting,
67
74
  type ResolvedTarget,
75
+ redactTargetInput,
68
76
  resolveAuth,
69
77
  resolveBooleanSetting,
70
78
  resolveCdb,
@@ -171,6 +179,8 @@ export interface ResolvedExecuteRequest {
171
179
  yes: boolean;
172
180
  verbose: boolean;
173
181
  warnings: readonly Warning[];
182
+ /** A CMR fleet command's reach, read before the write gate (#453). */
183
+ cmrFleet?: CmrFleetRead;
174
184
  }
175
185
 
176
186
  export async function execute(
@@ -202,6 +212,7 @@ export async function executeEnvelope(
202
212
  const trace: ValidationTrace = {};
203
213
  try {
204
214
  resolved = await resolveExecuteRequest(request, env);
215
+ resolved = await withCmrFleetScope(resolved);
205
216
  await assertWriteConfirmed(request, resolved);
206
217
  return await runResolvedExecute(resolved, trace);
207
218
  } catch (error) {
@@ -573,7 +584,7 @@ export function buildExecuteErrorEnvelope(
573
584
  warnings: [],
574
585
  tips: [],
575
586
  meta: {
576
- target: { input: request.targetInput },
587
+ target: { input: redactTargetInput(request.targetInput) },
577
588
  via: requestedVia,
578
589
  settings: {},
579
590
  operation: {
@@ -798,7 +809,7 @@ export function renderExecuteEnvelope(
798
809
  return toYaml(envelope);
799
810
  case "text":
800
811
  return envelope.ok
801
- ? renderExecuteSuccessText(envelope, options)
812
+ ? renderExecuteSuccessText(envelope)
802
813
  : formatCentrsErrorText(envelope.error, options);
803
814
  default:
804
815
  return exhaustiveOutputFormat(format);
@@ -1074,9 +1085,13 @@ async function assertWriteConfirmed(
1074
1085
  if (!isWriteShaped(resolved.canonical) || resolved.yes) {
1075
1086
  return;
1076
1087
  }
1088
+ const fleet = resolved.cmrFleet
1089
+ ? cmrFleetGate(resolved.cmrFleet, targetName(resolved))
1090
+ : undefined;
1077
1091
  if (request.stdinIsTty ?? process.stdin.isTTY) {
1078
1092
  const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
1079
- `Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
1093
+ fleet?.prompt ??
1094
+ `Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
1080
1095
  );
1081
1096
  if (confirmed) {
1082
1097
  return;
@@ -1085,6 +1100,7 @@ async function assertWriteConfirmed(
1085
1100
  throw new CentrsError({
1086
1101
  code: "usage/confirmation-required",
1087
1102
  summary:
1103
+ fleet?.summary ??
1088
1104
  "Write-shaped RouterOS execute commands require explicit confirmation.",
1089
1105
  remediation:
1090
1106
  "Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the command.",
@@ -1093,10 +1109,75 @@ async function assertWriteConfirmed(
1093
1109
  path: resolved.canonical.path,
1094
1110
  verb: resolved.canonical.verb,
1095
1111
  via: resolved.via.value,
1112
+ ...fleet?.context,
1096
1113
  },
1097
1114
  });
1098
1115
  }
1099
1116
 
1117
+ function targetName(resolved: ResolvedExecuteRequest): string {
1118
+ return (
1119
+ resolved.target.identity ?? resolved.target.input ?? resolved.target.host
1120
+ );
1121
+ }
1122
+
1123
+ /**
1124
+ * #453: a write-shaped CMR fleet command (`/cmr/device/reboot labels=all`) is
1125
+ * one request to the controller that acts on every device it selects. Read
1126
+ * the controller's inventory before the gate so the gate names that reach,
1127
+ * and add the fleet warnings, with or without `--yes`. Structured input only:
1128
+ * a script's selectors (`[find …]`, positionals) are not resolved.
1129
+ */
1130
+ async function withCmrFleetScope(
1131
+ resolved: ResolvedExecuteRequest,
1132
+ ): Promise<ResolvedExecuteRequest> {
1133
+ const { canonical } = resolved;
1134
+ if (canonical.mode !== "structured" || !isWriteShaped(canonical)) {
1135
+ return resolved;
1136
+ }
1137
+ const command = cmrFleetCommand(
1138
+ `${canonical.path}/${canonical.verb}`,
1139
+ canonical.attributes,
1140
+ );
1141
+ if (!command) return resolved;
1142
+ if (!cmrFleetReadable(resolved.via.value)) {
1143
+ return {
1144
+ ...resolved,
1145
+ cmrFleet: {
1146
+ scope: {
1147
+ command: command.command,
1148
+ unresolved: `${resolved.via.value} has no structured read`,
1149
+ },
1150
+ warnings: [],
1151
+ },
1152
+ };
1153
+ }
1154
+ const adapter = createProtocolAdapter({
1155
+ protocol: resolved.via.value,
1156
+ host: resolved.target.host,
1157
+ port: resolved.target.port,
1158
+ tls: resolved.target.tls,
1159
+ baseUrl: resolved.target.baseUrl,
1160
+ username: resolved.auth.username,
1161
+ password: resolved.auth.password,
1162
+ timeoutMs: resolved.timeoutMs.value,
1163
+ insecure: resolved.insecure.value,
1164
+ });
1165
+ try {
1166
+ const cmrFleet = await readCmrFleet(
1167
+ command,
1168
+ adapter,
1169
+ resolved.auth.username,
1170
+ );
1171
+ return {
1172
+ ...resolved,
1173
+ cmrFleet,
1174
+ warnings: [...resolved.warnings, ...cmrFleet.warnings],
1175
+ };
1176
+ } finally {
1177
+ await adapter.close();
1178
+ }
1179
+ }
1180
+
1100
1181
  export async function promptForWriteConfirmation(
1101
1182
  prompt: string,
1102
1183
  ): Promise<boolean> {
@@ -1450,27 +1531,28 @@ function applyMaxResultsBudget(
1450
1531
  return envelope;
1451
1532
  }
1452
1533
 
1453
- function renderExecuteSuccessText(
1454
- envelope: ExecuteSuccessEnvelope,
1455
- options: { verbose?: boolean },
1456
- ): string {
1457
- const lines: string[] = [];
1458
- if (options.verbose) {
1459
- lines.push(
1460
- `target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
1461
- );
1462
- lines.push(`via: ${envelope.meta.via}`);
1463
- if (envelope.meta.validation?.source) {
1464
- lines.push(`validation: ${envelope.meta.validation.source}`);
1465
- }
1466
- lines.push("");
1467
- }
1468
- if (typeof envelope.data === "string") {
1469
- lines.push(envelope.data);
1470
- } else {
1471
- lines.push(JSON.stringify(envelope.data, null, 2));
1534
+ /** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
1535
+ function renderExecuteSuccessText(envelope: ExecuteSuccessEnvelope): string {
1536
+ return typeof envelope.data === "string"
1537
+ ? envelope.data
1538
+ : JSON.stringify(envelope.data, null, 2);
1539
+ }
1540
+
1541
+ /**
1542
+ * The `--verbose` header of a text-mode execute result (stderr, #470). A
1543
+ * failure after resolution still names its target; one before has no
1544
+ * transport (`via: null`) and no header.
1545
+ */
1546
+ export function executeVerboseLines(envelope: ExecuteEnvelope): string[] {
1547
+ if (envelope.meta.via === null) return [];
1548
+ const lines = [
1549
+ `target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
1550
+ `via: ${envelope.meta.via}`,
1551
+ ];
1552
+ if (envelope.meta.validation?.source) {
1553
+ lines.push(`validation: ${envelope.meta.validation.source}`);
1472
1554
  }
1473
- return lines.join("\n");
1555
+ return lines;
1474
1556
  }
1475
1557
 
1476
1558
  function syntaxCause(error: unknown): unknown {