@tikoci/centrs 0.1.8 → 0.1.9-next.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
package/src/api.ts CHANGED
@@ -15,6 +15,14 @@
15
15
  * structured validation, confirmation, and result-envelope surface.
16
16
  */
17
17
 
18
+ import { assertNotCmrDashboardPath, cmrRunScriptWarnings } from "./core/cmr.ts";
19
+ import {
20
+ type CmrFleetRead,
21
+ cmrFleetCommand,
22
+ cmrFleetGate,
23
+ cmrFleetReadable,
24
+ readCmrFleet,
25
+ } from "./core/cmr-fleet.ts";
18
26
  import type {
19
27
  CentrsEnvelope,
20
28
  CentrsErrorEnvelope,
@@ -23,11 +31,13 @@ import type {
23
31
  SettingSource as CoreSettingSource,
24
32
  EnvelopeValidationMeta,
25
33
  EnvelopeValidationStage,
34
+ StreamLineEnvelope,
26
35
  Tip,
27
36
  ValidationTrace,
28
37
  Warning,
29
38
  } from "./core/envelope.ts";
30
39
  import { buildTip, notReachedReason } from "./core/envelope.ts";
40
+ import { sentWriteWarnings } from "./core/fanout.ts";
31
41
  import {
32
42
  inspectArgumentNames,
33
43
  inspectChildrenOrEmpty,
@@ -78,6 +88,7 @@ import {
78
88
  type ResolvedAuth,
79
89
  type ResolvedSetting,
80
90
  type ResolvedTarget,
91
+ redactTargetInput,
81
92
  resolveAuth,
82
93
  resolveBooleanSetting,
83
94
  resolveCdb,
@@ -211,19 +222,24 @@ export type ApiStreamStopReason =
211
222
  | "transport-error";
212
223
 
213
224
  /**
214
- * Per-line marker on `--stream` NDJSON output: a leading `notice`, one wire
215
- * reply, or the terminating summary. A `notice` (`data: null`, not counted)
216
- * comes first only when the request has advice in `tips` — what RouterOS will
217
- * not report for it — so even a watch that never gets a reply sees it. A frame's `reply` is `re` (a row; `data` is the row) or
218
- * `empty` (RouterOS `!empty`: zero rows; `data` is `null`). `afterStop` marks a
219
- * reply that arrived after centrs stopped the stream locally, such as the
220
- * `!empty` a cancelled listen sends; it is not evidence of an empty table.
225
+ * Per-line marker on `--stream` NDJSON output: the leading `started`, one wire
226
+ * reply, or the terminating summary. `started` (`data: null`, not counted)
227
+ * comes first on every stream that passed its preflight: it carries the full
228
+ * meta and any advice in `tips` — what RouterOS will not report for the
229
+ * request — so even a watch that never gets a reply sees both (#473). A frame
230
+ * carries only its `meta.operation`. Its `reply` is `re` (a row; `data` is the
231
+ * row) or `empty` (RouterOS `!empty`: zero rows; `data` is `null`), and
232
+ * `observedAt` is when centrs received it (host clock, ISO 8601; a slow reader
233
+ * does not move it). `afterStop` marks a reply that arrived after centrs
234
+ * stopped the stream locally, such as the `!empty` a cancelled listen sends;
235
+ * it is not evidence of an empty table.
221
236
  */
222
237
  export type ApiStreamMeta =
223
- | { kind: "notice" }
238
+ | { kind: "started" }
224
239
  | {
225
240
  kind: "frame";
226
241
  index: number;
242
+ observedAt: string;
227
243
  reply: "re" | "empty";
228
244
  afterStop?: true;
229
245
  }
@@ -263,6 +279,15 @@ export interface ApiOperationMeta {
263
279
  }
264
280
 
265
281
  export type ApiEnvelope = CentrsEnvelope<unknown, ApiOperationMeta>;
282
+ /** A `--stream` frame line: only its own `meta.operation` (#473). */
283
+ export type ApiStreamFrameEnvelope = StreamLineEnvelope<
284
+ unknown,
285
+ Pick<ApiOperationMeta, "kind" | "objectCount"> & {
286
+ stream: Extract<ApiStreamMeta, { kind: "frame" }>;
287
+ }
288
+ >;
289
+ /** One `--stream` line: a full envelope (`started`, `summary`, error) or a frame. */
290
+ export type ApiStreamLine = ApiEnvelope | ApiStreamFrameEnvelope;
266
291
  export type ApiSuccessEnvelope = CentrsSuccessEnvelope<
267
292
  unknown,
268
293
  ApiOperationMeta
@@ -309,6 +334,8 @@ export interface ResolvedApiRequest {
309
334
  yes: boolean;
310
335
  verbose: boolean;
311
336
  warnings: readonly Warning[];
337
+ /** A CMR fleet command's reach, read before the write gate (#453). */
338
+ cmrFleet?: CmrFleetRead;
312
339
  }
313
340
 
314
341
  export async function api(
@@ -340,6 +367,7 @@ export async function apiEnvelope(
340
367
  const trace: ValidationTrace = {};
341
368
  try {
342
369
  resolved = await resolveApiRequest(request, env);
370
+ resolved = await withCmrFleetScope(resolved);
343
371
  await assertApiWriteConfirmed(request, resolved);
344
372
  return await runResolvedApi(resolved, trace);
345
373
  } catch (error) {
@@ -393,6 +421,11 @@ export async function runResolvedApi(
393
421
  resolved === request
394
422
  ? protocolRequest
395
423
  : buildProtocolApiRequest(resolved),
424
+ {
425
+ onSent: () => {
426
+ trace.dispatched = true;
427
+ },
428
+ },
396
429
  );
397
430
  const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
398
431
  if (routerOsFailure) {
@@ -440,10 +473,11 @@ function apiRouterOsFailureFromResult(
440
473
  }
441
474
 
442
475
  /**
443
- * The `--stream` path: an async generator yielding one {@link ApiEnvelope} per
444
- * native reply (`!re` row or `!empty`) to the literal command, then a
445
- * terminating summary envelope (`data` = {@link ApiStreamSummary}). It never
446
- * rewrites the command; a `<menu>/listen` endpoint is how to follow changes.
476
+ * The `--stream` path: an async generator yielding a full-meta `started` line,
477
+ * one {@link ApiStreamFrameEnvelope} per native reply (`!re` row or `!empty`)
478
+ * to the literal command, then a terminating summary envelope (`data` =
479
+ * {@link ApiStreamSummary}). It never rewrites the command; a `<menu>/listen`
480
+ * endpoint is how to follow changes.
447
481
  * Streaming is native-api only — a `rest-api` (or otherwise unresolved)
448
482
  * transport yields a single error envelope. `--count`/`--duration` and
449
483
  * `externalSignal` (Ctrl-C) bound it. Errors before the first frame
@@ -457,7 +491,7 @@ export async function* apiStream(
457
491
  env: Record<string, string | undefined> = Bun.env,
458
492
  externalSignal?: AbortSignal,
459
493
  onListening?: () => void,
460
- ): AsyncGenerator<ApiEnvelope, void, void> {
494
+ ): AsyncGenerator<ApiStreamLine, void, void> {
461
495
  // `apiStream` is inherently the streaming surface, so force `stream` on the
462
496
  // request: otherwise a caller that omits it would resolve `via` to the
463
497
  // rest-api default and be rejected. An explicit `--via rest-api` still
@@ -466,6 +500,7 @@ export async function* apiStream(
466
500
  let resolved: ResolvedApiRequest | undefined;
467
501
  try {
468
502
  resolved = await resolveApiRequest(streamRequest, env);
503
+ resolved = await withCmrFleetScope(resolved);
469
504
  await assertApiWriteConfirmed(streamRequest, resolved);
470
505
  if (resolved.via.value !== "native-api") {
471
506
  throw new CentrsError({
@@ -490,16 +525,18 @@ async function* streamResolvedApi(
490
525
  request: ResolvedApiRequest,
491
526
  externalSignal?: AbortSignal,
492
527
  onListening?: () => void,
493
- ): AsyncGenerator<ApiEnvelope, void, void> {
528
+ ): AsyncGenerator<ApiStreamLine, void, void> {
494
529
  const backend = adapterForResolved(request);
495
530
 
496
531
  let resolved = request;
497
532
  let validation: EnvelopeValidationMeta;
533
+ let validationTips: Tip[] = [];
498
534
  const trace: ValidationTrace = {};
499
535
  try {
500
536
  if (resolved.validate.value) {
501
537
  const result = await validateApiRequest(resolved, backend, trace);
502
538
  validation = result.validation;
539
+ validationTips = result.tips;
503
540
  // The boolean probe below runs after the gate passed (GH#435).
504
541
  trace.validation = validation;
505
542
  resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
@@ -512,9 +549,27 @@ async function* streamResolvedApi(
512
549
  return;
513
550
  }
514
551
 
515
- // Request-shape advice is known before the first reply: deliver it first,
516
- // and keep it on whichever terminal summary follows (#409 review).
517
- const tips = streamTips(resolved);
552
+ // Request-shape advice is known before the first reply: deliver it on
553
+ // `started`, and keep it on whichever terminal summary follows (#409 review).
554
+ // The gate's own tips are the same advice the one-shot path returns.
555
+ const tips = [...validationTips, ...streamTips(resolved)];
556
+ // Arrival times in wire order, stamped by the synchronous reply tap: the
557
+ // session queues replies, so a slow reader must not move them. Read by a
558
+ // head index, not `shift()`, which is O(n) per frame on a backlog.
559
+ const arrivals: number[] = [];
560
+ let arrivalsHead = 0;
561
+ const nextArrival = (): number => {
562
+ const at = arrivals[arrivalsHead] ?? Date.now();
563
+ arrivalsHead += 1;
564
+ if (arrivalsHead >= arrivals.length) {
565
+ arrivals.length = 0;
566
+ arrivalsHead = 0;
567
+ } else if (arrivalsHead >= 1024 && arrivalsHead * 2 >= arrivals.length) {
568
+ arrivals.splice(0, arrivalsHead);
569
+ arrivalsHead = 0;
570
+ }
571
+ return at;
572
+ };
518
573
  const controller = new AbortController();
519
574
  const startedAt = Date.now();
520
575
  const counts = { frames: 0, rows: 0, empty: 0 };
@@ -555,15 +610,21 @@ async function* streamResolvedApi(
555
610
  }
556
611
 
557
612
  try {
558
- // A consumer may return immediately after this notice; keep it inside
613
+ // A consumer may return immediately after this line; keep it inside
559
614
  // the session cleanup scope, just like every wire reply.
560
- if (tips.length > 0) {
561
- yield streamNoticeEnvelope(resolved, validation, tips);
562
- }
615
+ yield streamStartedEnvelope(resolved, validation, tips);
563
616
  const protocolRequest = buildProtocolApiRequest(resolved);
564
617
  for await (const reply of backend.stream(protocolRequest, {
565
618
  signal: controller.signal,
566
- onListening,
619
+ onReply: (tapped) => {
620
+ if (tapped.type === "re" || tapped.type === "empty") {
621
+ arrivals.push(Date.now());
622
+ }
623
+ },
624
+ onListening: () => {
625
+ trace.dispatched = true;
626
+ onListening?.();
627
+ },
567
628
  onCancelUnacknowledged: () => {
568
629
  cancelUnacknowledged = true;
569
630
  },
@@ -575,7 +636,7 @@ async function* streamResolvedApi(
575
636
  counts.frames += 1;
576
637
  if (reply.type === "empty") counts.empty += 1;
577
638
  else counts.rows += 1;
578
- yield streamFrameEnvelope(resolved, validation, reply, counts.frames);
639
+ yield streamFrameEnvelope(reply, counts.frames, nextArrival());
579
640
  // `--count` counts rows only: an empty tick must never use up the
580
641
  // count before the data it is waiting for arrives.
581
642
  if (
@@ -602,7 +663,7 @@ async function* streamResolvedApi(
602
663
  done,
603
664
  );
604
665
  } catch (error) {
605
- const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
666
+ const envelope = buildApiErrorEnvelopeFromResolved(resolved, error, trace);
606
667
  envelope.meta.validation = validation;
607
668
  envelope.warnings = [...envelope.warnings, ...cancellationWarnings()];
608
669
  envelope.tips = [...envelope.tips, ...tips];
@@ -628,29 +689,39 @@ async function* streamResolvedApi(
628
689
  }
629
690
 
630
691
  function streamFrameEnvelope(
631
- resolved: ResolvedApiRequest,
632
- validation: EnvelopeValidationMeta,
633
692
  reply: ProtocolStreamReply,
634
693
  index: number,
635
- ): ApiSuccessEnvelope {
694
+ observedAt: number,
695
+ ): ApiStreamFrameEnvelope {
636
696
  const data = reply.type === "empty" ? null : reply.attributes;
637
- const meta = metaFromResolved(resolved, validation, data);
638
- meta.operation.stream = {
639
- kind: "frame",
640
- index,
641
- reply: reply.type,
642
- ...(reply.afterStop ? { afterStop: true as const } : {}),
697
+ return {
698
+ ok: true,
699
+ data,
700
+ warnings: [],
701
+ tips: [],
702
+ meta: {
703
+ operation: {
704
+ kind: "api",
705
+ objectCount: countResultObjects(data),
706
+ stream: {
707
+ kind: "frame",
708
+ index,
709
+ observedAt: new Date(observedAt).toISOString(),
710
+ reply: reply.type,
711
+ ...(reply.afterStop ? { afterStop: true as const } : {}),
712
+ },
713
+ },
714
+ },
643
715
  };
644
- return { ok: true, data, warnings: [], tips: [], meta };
645
716
  }
646
717
 
647
- function streamNoticeEnvelope(
718
+ function streamStartedEnvelope(
648
719
  resolved: ResolvedApiRequest,
649
720
  validation: EnvelopeValidationMeta,
650
721
  tips: Tip[],
651
722
  ): ApiSuccessEnvelope {
652
723
  const meta = metaFromResolved(resolved, validation, null);
653
- meta.operation.stream = { kind: "notice" };
724
+ meta.operation.stream = { kind: "started" };
654
725
  return { ok: true, data: null, warnings: [], tips, meta };
655
726
  }
656
727
 
@@ -1029,9 +1100,10 @@ export async function resolveApiRequest(
1029
1100
  insecure,
1030
1101
  yes: request.yes ?? false,
1031
1102
  verbose: request.verbose ?? false,
1032
- warnings: connection
1033
- ? [...connection.warnings]
1034
- : (cdbResolution?.warnings ?? []),
1103
+ warnings: [
1104
+ ...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
1105
+ ...(verb === "run" ? cmrRunScriptWarnings(normalized.path) : []),
1106
+ ],
1035
1107
  };
1036
1108
  }
1037
1109
 
@@ -1081,8 +1153,9 @@ export function mapMethodToVerb(method: ApiMethod): ApiVerb {
1081
1153
  }
1082
1154
 
1083
1155
  /**
1084
- * Read-only **iff** the method is `GET`, or the terminal path verb is
1085
- * `print`/`get`/`listen`. Everything else is a write needing confirmation. Keyed
1156
+ * Read-only **iff** the method is `GET`, the terminal path verb is
1157
+ * `print`/`get`/`listen`, or it is a POST to exactly `/console/inspect`. Everything
1158
+ * else is a write needing confirmation. Keyed
1086
1159
  * on the verb/method, not the wire HTTP method — a `POST …/print` paged read does
1087
1160
  * not prompt; `--listen` never relaxes the gate (a streaming-but-mutating
1088
1161
  * `/system/license/renew` still confirms). See `commands/api/README.md`.
@@ -1091,6 +1164,13 @@ export function isApiMutating(method: ApiMethod, path: string): boolean {
1091
1164
  if (method === "GET") {
1092
1165
  return false;
1093
1166
  }
1167
+ // `/console/inspect` describes the command tree and parses input; it never
1168
+ // runs it, so a POST to it (the only way to pass `request=`) is a read
1169
+ // (#458). POST and the exact path only: PUT/PATCH/DELETE map to
1170
+ // add/set/remove, and an abbreviation stays a write.
1171
+ if (method === "POST" && pathTokens(path).join("/") === "console/inspect") {
1172
+ return false;
1173
+ }
1094
1174
  const terminal = pathTokens(path).at(-1);
1095
1175
  if (terminal === "print" || terminal === "get" || terminal === "listen") {
1096
1176
  return false;
@@ -1494,9 +1574,18 @@ async function assertApiWriteConfirmed(
1494
1574
  if (!isApiMutating(resolved.method, resolved.path) || resolved.yes) {
1495
1575
  return;
1496
1576
  }
1577
+ const fleet = resolved.cmrFleet
1578
+ ? cmrFleetGate(
1579
+ resolved.cmrFleet,
1580
+ resolved.target.identity ??
1581
+ resolved.target.input ??
1582
+ resolved.target.host,
1583
+ )
1584
+ : undefined;
1497
1585
  if (request.stdinIsTty ?? process.stdin.isTTY) {
1498
1586
  const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
1499
- `Run mutating ${resolved.method} ${resolved.path} over ${resolved.via.value}? Type yes to continue: `,
1587
+ fleet?.prompt ??
1588
+ `Run mutating ${resolved.method} ${resolved.path} over ${resolved.via.value}? Type yes to continue: `,
1500
1589
  );
1501
1590
  if (confirmed) {
1502
1591
  return;
@@ -1504,7 +1593,9 @@ async function assertApiWriteConfirmed(
1504
1593
  }
1505
1594
  throw new CentrsError({
1506
1595
  code: "usage/confirmation-required",
1507
- summary: `A mutating ${resolved.method} ${resolved.path} request requires explicit confirmation.`,
1596
+ summary:
1597
+ fleet?.summary ??
1598
+ `A mutating ${resolved.method} ${resolved.path} request requires explicit confirmation.`,
1508
1599
  remediation:
1509
1600
  "Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the request.",
1510
1601
  context: {
@@ -1512,10 +1603,61 @@ async function assertApiWriteConfirmed(
1512
1603
  path: resolved.path,
1513
1604
  method: resolved.method,
1514
1605
  via: resolved.via.value,
1606
+ ...fleet?.context,
1515
1607
  },
1516
1608
  });
1517
1609
  }
1518
1610
 
1611
+ /**
1612
+ * #453: a mutating CMR fleet request (`POST /cmr/device/reboot`, `PUT
1613
+ * /cmr/vlan`) is one request to the controller that acts on every device it
1614
+ * selects. Read the controller's inventory before the gate so the gate names
1615
+ * that reach, and add the fleet warnings, with or without `--yes`. A `PUT`,
1616
+ * `PATCH` or `DELETE` is the menu's `add`, `set` or `remove`; the `*id` is its
1617
+ * `numbers`.
1618
+ */
1619
+ async function withCmrFleetScope(
1620
+ resolved: ResolvedApiRequest,
1621
+ ): Promise<ResolvedApiRequest> {
1622
+ if (resolved.scriptMode || !isApiMutating(resolved.method, resolved.path)) {
1623
+ return resolved;
1624
+ }
1625
+ const command = cmrFleetCommand(
1626
+ resolved.verb === "run"
1627
+ ? resolved.path
1628
+ : `${resolved.path}/${resolved.verb}`,
1629
+ resolved.id === undefined
1630
+ ? resolved.body
1631
+ : { ...resolved.body, numbers: resolved.id },
1632
+ );
1633
+ if (!command || !cmrFleetReadable(resolved.via.value)) return resolved;
1634
+ const adapter = createProtocolAdapter({
1635
+ protocol: resolved.via.value,
1636
+ host: resolved.target.host,
1637
+ port: resolved.target.port,
1638
+ tls: resolved.target.tls,
1639
+ baseUrl: resolved.target.baseUrl,
1640
+ username: resolved.auth.username,
1641
+ password: resolved.auth.password,
1642
+ timeoutMs: resolved.timeoutMs.value,
1643
+ insecure: resolved.insecure.value,
1644
+ });
1645
+ try {
1646
+ const cmrFleet = await readCmrFleet(
1647
+ command,
1648
+ adapter,
1649
+ resolved.auth.username,
1650
+ );
1651
+ return {
1652
+ ...resolved,
1653
+ cmrFleet,
1654
+ warnings: [...resolved.warnings, ...cmrFleet.warnings],
1655
+ };
1656
+ } finally {
1657
+ await adapter.close();
1658
+ }
1659
+ }
1660
+
1519
1661
  function assertStreamCapability(resolved: ResolvedApiRequest): void {
1520
1662
  if (!resolved.stream) {
1521
1663
  return;
@@ -1534,7 +1676,7 @@ function assertStreamCapability(resolved: ResolvedApiRequest): void {
1534
1676
  summary:
1535
1677
  "`--stream` yields incremental replies instead of a one-shot `api` result.",
1536
1678
  remediation:
1537
- "Consume the stream via `apiStream()` (library) or `centrs api … --stream` (CLI); both yield an NDJSON envelope per reply frame plus a final summary.",
1679
+ "Consume the stream via `apiStream()` (library) or `centrs api … --stream` (CLI); both yield a `started` line, a line per reply frame, then a final summary.",
1538
1680
  context: { via: resolved.via.value, capability: "stream" },
1539
1681
  });
1540
1682
  }
@@ -1678,7 +1820,7 @@ export function buildApiErrorEnvelope(
1678
1820
  warnings: [],
1679
1821
  tips: [],
1680
1822
  meta: {
1681
- target: { input: request.targetInput },
1823
+ target: { input: redactTargetInput(request.targetInput) },
1682
1824
  via: requestedVia,
1683
1825
  settings: {},
1684
1826
  operation: {
@@ -1718,7 +1860,14 @@ export function buildApiErrorEnvelopeFromResolved(
1718
1860
  return {
1719
1861
  ok: false,
1720
1862
  error: serializeCentrsError(centrsError),
1721
- warnings: [...resolved.warnings],
1863
+ warnings: [
1864
+ ...resolved.warnings,
1865
+ ...sentWriteWarnings(
1866
+ isApiMutating(resolved.method, resolved.path),
1867
+ centrsError,
1868
+ trace,
1869
+ ),
1870
+ ],
1722
1871
  tips: [],
1723
1872
  meta: metaFromResolved(
1724
1873
  resolved,
@@ -1912,7 +2061,7 @@ export function renderApiEnvelope(
1912
2061
  return toYaml(envelope);
1913
2062
  case "text":
1914
2063
  return envelope.ok
1915
- ? renderApiSuccessText(envelope, options)
2064
+ ? renderApiSuccessText(envelope)
1916
2065
  : formatCentrsErrorText(envelope.error, options);
1917
2066
  default:
1918
2067
  return exhaustiveFormat(format);
@@ -1924,11 +2073,12 @@ export function renderApiEnvelope(
1924
2073
  * object per line) for both `json` and `yaml`, and a concise human row for
1925
2074
  * `text`. `yaml` deliberately falls back to NDJSON here — YAML's multi-line
1926
2075
  * document form is incompatible with line-delimited streaming. Unlike
1927
- * {@link renderApiEnvelope} this never pretty-prints — every frame and the
1928
- * summary are one line each, so a consumer can read the stream line by line.
2076
+ * {@link renderApiEnvelope} this never pretty-prints — `started`, every frame
2077
+ * and the summary are one line each, so a consumer can read the stream line
2078
+ * by line. In text, a `started` line without tips renders as an empty string.
1929
2079
  */
1930
2080
  export function renderApiStreamLine(
1931
- envelope: ApiEnvelope,
2081
+ envelope: ApiStreamLine,
1932
2082
  format: ApiOutputFormat,
1933
2083
  options: { raw?: boolean; verbose?: boolean } = {},
1934
2084
  ): string {
@@ -1951,13 +2101,15 @@ export function renderApiStreamLine(
1951
2101
  return JSON.stringify(envelope);
1952
2102
  }
1953
2103
 
1954
- function renderApiStreamFrameText(envelope: ApiSuccessEnvelope): string {
2104
+ function renderApiStreamFrameText(
2105
+ envelope: Exclude<ApiStreamLine, ApiErrorEnvelope>,
2106
+ ): string {
1955
2107
  const stream = envelope.meta.operation?.stream;
1956
2108
  if (stream?.kind === "summary") {
1957
2109
  const empty = stream.empty > 0 ? `, ${stream.empty} empty` : "";
1958
2110
  return `— ${stream.stopReason}: ${stream.rows} row(s)${empty} in ${stream.durationMs}ms`;
1959
2111
  }
1960
- if (stream?.kind === "notice") {
2112
+ if (stream?.kind === "started") {
1961
2113
  return envelope.tips
1962
2114
  .map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
1963
2115
  .join("\n");
@@ -1985,24 +2137,24 @@ function rawErrorPayload(
1985
2137
  return payload;
1986
2138
  }
1987
2139
 
1988
- function renderApiSuccessText(
1989
- envelope: ApiSuccessEnvelope,
1990
- options: { verbose?: boolean },
1991
- ): string {
1992
- const lines: string[] = [];
1993
- if (options.verbose) {
1994
- lines.push(`via: ${envelope.meta.via}`);
1995
- if (envelope.meta.validation?.source) {
1996
- lines.push(`validation: ${envelope.meta.validation.source}`);
1997
- }
1998
- lines.push("");
2140
+ /** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
2141
+ function renderApiSuccessText(envelope: ApiSuccessEnvelope): string {
2142
+ return typeof envelope.data === "string"
2143
+ ? envelope.data
2144
+ : JSON.stringify(envelope.data, null, 2);
2145
+ }
2146
+
2147
+ /**
2148
+ * The `--verbose` header of a text-mode api result (stderr, #470); none for a
2149
+ * failure before resolution (`via: null`).
2150
+ */
2151
+ export function apiVerboseLines(envelope: ApiEnvelope): string[] {
2152
+ if (envelope.meta.via === null) return [];
2153
+ const lines = [`via: ${envelope.meta.via}`];
2154
+ if (envelope.meta.validation?.source) {
2155
+ lines.push(`validation: ${envelope.meta.validation.source}`);
1999
2156
  }
2000
- lines.push(
2001
- typeof envelope.data === "string"
2002
- ? envelope.data
2003
- : JSON.stringify(envelope.data, null, 2),
2004
- );
2005
- return lines.join("\n");
2157
+ return lines;
2006
2158
  }
2007
2159
 
2008
2160
  export function validateApiRequestShape(request: ApiRequest): void {
@@ -2020,6 +2172,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
2020
2172
  });
2021
2173
  }
2022
2174
  const normalized = normalizeApiEndpoint(request.endpoint);
2175
+ assertNotCmrDashboardPath(normalized.path);
2023
2176
  if (pathTokens(normalized.path).length === 0) {
2024
2177
  throw new CentrsError({
2025
2178
  code: "input/invalid-path",
@@ -2096,11 +2249,17 @@ export function apiRequestSummaryFromRequest(
2096
2249
  validate: summarizedValidate(request),
2097
2250
  raw: request.raw ?? false,
2098
2251
  format: resolveErrorFormat(request, env),
2099
- query: summaryQuery(request),
2100
- proplist: buildApiProplist(request),
2252
+ // Empty lists are omitted, as in the resolved summary, so a fan-out
2253
+ // target's request compares equal to the outer one (#461).
2254
+ query: nonEmpty(summaryQuery(request)),
2255
+ proplist: nonEmpty(buildApiProplist(request)),
2101
2256
  };
2102
2257
  }
2103
2258
 
2259
+ function nonEmpty(list: string[] | undefined): string[] | undefined {
2260
+ return list && list.length > 0 ? list : undefined;
2261
+ }
2262
+
2104
2263
  /** {@link buildApiQuery} for an error summary, which must not throw on the expression it reports. */
2105
2264
  function summaryQuery(request: ApiRequest): string[] | undefined {
2106
2265
  try {
package/src/btest.ts CHANGED
@@ -37,7 +37,12 @@ import {
37
37
  handleBtestServerConnection,
38
38
  runBtestClientSession,
39
39
  } from "./protocols/btest-session.ts";
40
- import { resolveAuth, resolveCdb, resolveTarget } from "./resolver/index.ts";
40
+ import {
41
+ redactTargetInput,
42
+ resolveAuth,
43
+ resolveCdb,
44
+ resolveTarget,
45
+ } from "./resolver/index.ts";
41
46
 
42
47
  export { BTEST_PORT, BTEST_UDP_PORT_START };
43
48
 
@@ -424,7 +429,7 @@ function buildClientErrorEnvelope(
424
429
  target: {
425
430
  ...(request.host !== undefined ? { host: request.host } : {}),
426
431
  ...(request.targetInput !== undefined
427
- ? { input: request.targetInput }
432
+ ? { input: redactTargetInput(request.targetInput) }
428
433
  : {}),
429
434
  port: operation.controlPort,
430
435
  },