@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
package/src/api.ts CHANGED
@@ -16,6 +16,13 @@
16
16
  */
17
17
 
18
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";
19
26
  import type {
20
27
  CentrsEnvelope,
21
28
  CentrsErrorEnvelope,
@@ -24,6 +31,7 @@ import type {
24
31
  SettingSource as CoreSettingSource,
25
32
  EnvelopeValidationMeta,
26
33
  EnvelopeValidationStage,
34
+ StreamLineEnvelope,
27
35
  Tip,
28
36
  ValidationTrace,
29
37
  Warning,
@@ -80,6 +88,7 @@ import {
80
88
  type ResolvedAuth,
81
89
  type ResolvedSetting,
82
90
  type ResolvedTarget,
91
+ redactTargetInput,
83
92
  resolveAuth,
84
93
  resolveBooleanSetting,
85
94
  resolveCdb,
@@ -213,19 +222,24 @@ export type ApiStreamStopReason =
213
222
  | "transport-error";
214
223
 
215
224
  /**
216
- * Per-line marker on `--stream` NDJSON output: a leading `notice`, one wire
217
- * reply, or the terminating summary. A `notice` (`data: null`, not counted)
218
- * comes first only when the request has advice in `tips` — what RouterOS will
219
- * 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
220
- * `empty` (RouterOS `!empty`: zero rows; `data` is `null`). `afterStop` marks a
221
- * reply that arrived after centrs stopped the stream locally, such as the
222
- * `!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.
223
236
  */
224
237
  export type ApiStreamMeta =
225
- | { kind: "notice" }
238
+ | { kind: "started" }
226
239
  | {
227
240
  kind: "frame";
228
241
  index: number;
242
+ observedAt: string;
229
243
  reply: "re" | "empty";
230
244
  afterStop?: true;
231
245
  }
@@ -265,6 +279,15 @@ export interface ApiOperationMeta {
265
279
  }
266
280
 
267
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;
268
291
  export type ApiSuccessEnvelope = CentrsSuccessEnvelope<
269
292
  unknown,
270
293
  ApiOperationMeta
@@ -311,6 +334,8 @@ export interface ResolvedApiRequest {
311
334
  yes: boolean;
312
335
  verbose: boolean;
313
336
  warnings: readonly Warning[];
337
+ /** A CMR fleet command's reach, read before the write gate (#453). */
338
+ cmrFleet?: CmrFleetRead;
314
339
  }
315
340
 
316
341
  export async function api(
@@ -342,6 +367,7 @@ export async function apiEnvelope(
342
367
  const trace: ValidationTrace = {};
343
368
  try {
344
369
  resolved = await resolveApiRequest(request, env);
370
+ resolved = await withCmrFleetScope(resolved);
345
371
  await assertApiWriteConfirmed(request, resolved);
346
372
  return await runResolvedApi(resolved, trace);
347
373
  } catch (error) {
@@ -447,10 +473,11 @@ function apiRouterOsFailureFromResult(
447
473
  }
448
474
 
449
475
  /**
450
- * The `--stream` path: an async generator yielding one {@link ApiEnvelope} per
451
- * native reply (`!re` row or `!empty`) to the literal command, then a
452
- * terminating summary envelope (`data` = {@link ApiStreamSummary}). It never
453
- * 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.
454
481
  * Streaming is native-api only — a `rest-api` (or otherwise unresolved)
455
482
  * transport yields a single error envelope. `--count`/`--duration` and
456
483
  * `externalSignal` (Ctrl-C) bound it. Errors before the first frame
@@ -464,7 +491,7 @@ export async function* apiStream(
464
491
  env: Record<string, string | undefined> = Bun.env,
465
492
  externalSignal?: AbortSignal,
466
493
  onListening?: () => void,
467
- ): AsyncGenerator<ApiEnvelope, void, void> {
494
+ ): AsyncGenerator<ApiStreamLine, void, void> {
468
495
  // `apiStream` is inherently the streaming surface, so force `stream` on the
469
496
  // request: otherwise a caller that omits it would resolve `via` to the
470
497
  // rest-api default and be rejected. An explicit `--via rest-api` still
@@ -473,6 +500,7 @@ export async function* apiStream(
473
500
  let resolved: ResolvedApiRequest | undefined;
474
501
  try {
475
502
  resolved = await resolveApiRequest(streamRequest, env);
503
+ resolved = await withCmrFleetScope(resolved);
476
504
  await assertApiWriteConfirmed(streamRequest, resolved);
477
505
  if (resolved.via.value !== "native-api") {
478
506
  throw new CentrsError({
@@ -497,16 +525,18 @@ async function* streamResolvedApi(
497
525
  request: ResolvedApiRequest,
498
526
  externalSignal?: AbortSignal,
499
527
  onListening?: () => void,
500
- ): AsyncGenerator<ApiEnvelope, void, void> {
528
+ ): AsyncGenerator<ApiStreamLine, void, void> {
501
529
  const backend = adapterForResolved(request);
502
530
 
503
531
  let resolved = request;
504
532
  let validation: EnvelopeValidationMeta;
533
+ let validationTips: Tip[] = [];
505
534
  const trace: ValidationTrace = {};
506
535
  try {
507
536
  if (resolved.validate.value) {
508
537
  const result = await validateApiRequest(resolved, backend, trace);
509
538
  validation = result.validation;
539
+ validationTips = result.tips;
510
540
  // The boolean probe below runs after the gate passed (GH#435).
511
541
  trace.validation = validation;
512
542
  resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
@@ -519,9 +549,27 @@ async function* streamResolvedApi(
519
549
  return;
520
550
  }
521
551
 
522
- // Request-shape advice is known before the first reply: deliver it first,
523
- // and keep it on whichever terminal summary follows (#409 review).
524
- 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
+ };
525
573
  const controller = new AbortController();
526
574
  const startedAt = Date.now();
527
575
  const counts = { frames: 0, rows: 0, empty: 0 };
@@ -562,14 +610,17 @@ async function* streamResolvedApi(
562
610
  }
563
611
 
564
612
  try {
565
- // A consumer may return immediately after this notice; keep it inside
613
+ // A consumer may return immediately after this line; keep it inside
566
614
  // the session cleanup scope, just like every wire reply.
567
- if (tips.length > 0) {
568
- yield streamNoticeEnvelope(resolved, validation, tips);
569
- }
615
+ yield streamStartedEnvelope(resolved, validation, tips);
570
616
  const protocolRequest = buildProtocolApiRequest(resolved);
571
617
  for await (const reply of backend.stream(protocolRequest, {
572
618
  signal: controller.signal,
619
+ onReply: (tapped) => {
620
+ if (tapped.type === "re" || tapped.type === "empty") {
621
+ arrivals.push(Date.now());
622
+ }
623
+ },
573
624
  onListening: () => {
574
625
  trace.dispatched = true;
575
626
  onListening?.();
@@ -585,7 +636,7 @@ async function* streamResolvedApi(
585
636
  counts.frames += 1;
586
637
  if (reply.type === "empty") counts.empty += 1;
587
638
  else counts.rows += 1;
588
- yield streamFrameEnvelope(resolved, validation, reply, counts.frames);
639
+ yield streamFrameEnvelope(reply, counts.frames, nextArrival());
589
640
  // `--count` counts rows only: an empty tick must never use up the
590
641
  // count before the data it is waiting for arrives.
591
642
  if (
@@ -638,29 +689,39 @@ async function* streamResolvedApi(
638
689
  }
639
690
 
640
691
  function streamFrameEnvelope(
641
- resolved: ResolvedApiRequest,
642
- validation: EnvelopeValidationMeta,
643
692
  reply: ProtocolStreamReply,
644
693
  index: number,
645
- ): ApiSuccessEnvelope {
694
+ observedAt: number,
695
+ ): ApiStreamFrameEnvelope {
646
696
  const data = reply.type === "empty" ? null : reply.attributes;
647
- const meta = metaFromResolved(resolved, validation, data);
648
- meta.operation.stream = {
649
- kind: "frame",
650
- index,
651
- reply: reply.type,
652
- ...(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
+ },
653
715
  };
654
- return { ok: true, data, warnings: [], tips: [], meta };
655
716
  }
656
717
 
657
- function streamNoticeEnvelope(
718
+ function streamStartedEnvelope(
658
719
  resolved: ResolvedApiRequest,
659
720
  validation: EnvelopeValidationMeta,
660
721
  tips: Tip[],
661
722
  ): ApiSuccessEnvelope {
662
723
  const meta = metaFromResolved(resolved, validation, null);
663
- meta.operation.stream = { kind: "notice" };
724
+ meta.operation.stream = { kind: "started" };
664
725
  return { ok: true, data: null, warnings: [], tips, meta };
665
726
  }
666
727
 
@@ -1092,8 +1153,9 @@ export function mapMethodToVerb(method: ApiMethod): ApiVerb {
1092
1153
  }
1093
1154
 
1094
1155
  /**
1095
- * Read-only **iff** the method is `GET`, or the terminal path verb is
1096
- * `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
1097
1159
  * on the verb/method, not the wire HTTP method — a `POST …/print` paged read does
1098
1160
  * not prompt; `--listen` never relaxes the gate (a streaming-but-mutating
1099
1161
  * `/system/license/renew` still confirms). See `commands/api/README.md`.
@@ -1102,6 +1164,13 @@ export function isApiMutating(method: ApiMethod, path: string): boolean {
1102
1164
  if (method === "GET") {
1103
1165
  return false;
1104
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
+ }
1105
1174
  const terminal = pathTokens(path).at(-1);
1106
1175
  if (terminal === "print" || terminal === "get" || terminal === "listen") {
1107
1176
  return false;
@@ -1505,9 +1574,18 @@ async function assertApiWriteConfirmed(
1505
1574
  if (!isApiMutating(resolved.method, resolved.path) || resolved.yes) {
1506
1575
  return;
1507
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;
1508
1585
  if (request.stdinIsTty ?? process.stdin.isTTY) {
1509
1586
  const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
1510
- `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: `,
1511
1589
  );
1512
1590
  if (confirmed) {
1513
1591
  return;
@@ -1515,7 +1593,9 @@ async function assertApiWriteConfirmed(
1515
1593
  }
1516
1594
  throw new CentrsError({
1517
1595
  code: "usage/confirmation-required",
1518
- 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.`,
1519
1599
  remediation:
1520
1600
  "Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the request.",
1521
1601
  context: {
@@ -1523,10 +1603,61 @@ async function assertApiWriteConfirmed(
1523
1603
  path: resolved.path,
1524
1604
  method: resolved.method,
1525
1605
  via: resolved.via.value,
1606
+ ...fleet?.context,
1526
1607
  },
1527
1608
  });
1528
1609
  }
1529
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
+
1530
1661
  function assertStreamCapability(resolved: ResolvedApiRequest): void {
1531
1662
  if (!resolved.stream) {
1532
1663
  return;
@@ -1545,7 +1676,7 @@ function assertStreamCapability(resolved: ResolvedApiRequest): void {
1545
1676
  summary:
1546
1677
  "`--stream` yields incremental replies instead of a one-shot `api` result.",
1547
1678
  remediation:
1548
- "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.",
1549
1680
  context: { via: resolved.via.value, capability: "stream" },
1550
1681
  });
1551
1682
  }
@@ -1689,7 +1820,7 @@ export function buildApiErrorEnvelope(
1689
1820
  warnings: [],
1690
1821
  tips: [],
1691
1822
  meta: {
1692
- target: { input: request.targetInput },
1823
+ target: { input: redactTargetInput(request.targetInput) },
1693
1824
  via: requestedVia,
1694
1825
  settings: {},
1695
1826
  operation: {
@@ -1930,7 +2061,7 @@ export function renderApiEnvelope(
1930
2061
  return toYaml(envelope);
1931
2062
  case "text":
1932
2063
  return envelope.ok
1933
- ? renderApiSuccessText(envelope, options)
2064
+ ? renderApiSuccessText(envelope)
1934
2065
  : formatCentrsErrorText(envelope.error, options);
1935
2066
  default:
1936
2067
  return exhaustiveFormat(format);
@@ -1942,11 +2073,12 @@ export function renderApiEnvelope(
1942
2073
  * object per line) for both `json` and `yaml`, and a concise human row for
1943
2074
  * `text`. `yaml` deliberately falls back to NDJSON here — YAML's multi-line
1944
2075
  * document form is incompatible with line-delimited streaming. Unlike
1945
- * {@link renderApiEnvelope} this never pretty-prints — every frame and the
1946
- * 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.
1947
2079
  */
1948
2080
  export function renderApiStreamLine(
1949
- envelope: ApiEnvelope,
2081
+ envelope: ApiStreamLine,
1950
2082
  format: ApiOutputFormat,
1951
2083
  options: { raw?: boolean; verbose?: boolean } = {},
1952
2084
  ): string {
@@ -1969,13 +2101,15 @@ export function renderApiStreamLine(
1969
2101
  return JSON.stringify(envelope);
1970
2102
  }
1971
2103
 
1972
- function renderApiStreamFrameText(envelope: ApiSuccessEnvelope): string {
2104
+ function renderApiStreamFrameText(
2105
+ envelope: Exclude<ApiStreamLine, ApiErrorEnvelope>,
2106
+ ): string {
1973
2107
  const stream = envelope.meta.operation?.stream;
1974
2108
  if (stream?.kind === "summary") {
1975
2109
  const empty = stream.empty > 0 ? `, ${stream.empty} empty` : "";
1976
2110
  return `— ${stream.stopReason}: ${stream.rows} row(s)${empty} in ${stream.durationMs}ms`;
1977
2111
  }
1978
- if (stream?.kind === "notice") {
2112
+ if (stream?.kind === "started") {
1979
2113
  return envelope.tips
1980
2114
  .map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
1981
2115
  .join("\n");
@@ -2003,24 +2137,24 @@ function rawErrorPayload(
2003
2137
  return payload;
2004
2138
  }
2005
2139
 
2006
- function renderApiSuccessText(
2007
- envelope: ApiSuccessEnvelope,
2008
- options: { verbose?: boolean },
2009
- ): string {
2010
- const lines: string[] = [];
2011
- if (options.verbose) {
2012
- lines.push(`via: ${envelope.meta.via}`);
2013
- if (envelope.meta.validation?.source) {
2014
- lines.push(`validation: ${envelope.meta.validation.source}`);
2015
- }
2016
- 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}`);
2017
2156
  }
2018
- lines.push(
2019
- typeof envelope.data === "string"
2020
- ? envelope.data
2021
- : JSON.stringify(envelope.data, null, 2),
2022
- );
2023
- return lines.join("\n");
2157
+ return lines;
2024
2158
  }
2025
2159
 
2026
2160
  export function validateApiRequestShape(request: ApiRequest): void {
@@ -2115,11 +2249,17 @@ export function apiRequestSummaryFromRequest(
2115
2249
  validate: summarizedValidate(request),
2116
2250
  raw: request.raw ?? false,
2117
2251
  format: resolveErrorFormat(request, env),
2118
- query: summaryQuery(request),
2119
- 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)),
2120
2256
  };
2121
2257
  }
2122
2258
 
2259
+ function nonEmpty(list: string[] | undefined): string[] | undefined {
2260
+ return list && list.length > 0 ? list : undefined;
2261
+ }
2262
+
2123
2263
  /** {@link buildApiQuery} for an error summary, which must not throw on the expression it reports. */
2124
2264
  function summaryQuery(request: ApiRequest): string[] | undefined {
2125
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
  },
package/src/cli/api.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  apiEnvelope,
5
5
  apiOutputFormats,
6
6
  apiStream,
7
+ apiVerboseLines,
7
8
  buildApiErrorEnvelope,
8
9
  normalizeApiEndpoint,
9
10
  removedListenError,
@@ -26,6 +27,8 @@ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
26
27
  import {
27
28
  type CliCommandMetadata,
28
29
  expectValue,
30
+ printTextHeader,
31
+ printTextNotes,
29
32
  renderCommandHelp,
30
33
  unknownFlagError,
31
34
  } from "./common.ts";
@@ -106,7 +109,7 @@ export const apiCommand: CliCommandMetadata = {
106
109
  {
107
110
  flag: "--stream",
108
111
  description:
109
- "Stream the command's native replies (`!re` rows and `!empty`) as NDJSON, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it.",
112
+ "Stream the command's native replies (`!re` rows and `!empty`) as NDJSON: a `started` line, a line per reply, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it.",
110
113
  },
111
114
  {
112
115
  flag: "--count",
@@ -190,7 +193,7 @@ export const apiCommand: CliCommandMetadata = {
190
193
  },
191
194
  {
192
195
  flag: "--verbose",
193
- description: "Include additional context in text output.",
196
+ description: "Include additional context in text output, on stderr.",
194
197
  },
195
198
  ],
196
199
  };
@@ -542,16 +545,22 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
542
545
  )?.request?.format ??
543
546
  parsed.format ??
544
547
  "json";
548
+ // `--raw` is a bare payload by contract; `--json` carries the notes.
549
+ const text = resolvedFormat === "text" && !parsed.raw;
550
+ if (text && parsed.verbose) {
551
+ printTextHeader(apiVerboseLines(envelope));
552
+ }
545
553
  const rendered = renderApiEnvelope(envelope, resolvedFormat, {
546
554
  raw: parsed.raw,
547
555
  verbose: parsed.verbose,
548
556
  });
549
557
  if (envelope.ok) {
550
558
  console.log(rendered);
551
- return 0;
559
+ } else {
560
+ console.error(rendered);
552
561
  }
553
- console.error(rendered);
554
- return 1;
562
+ if (text) printTextNotes(envelope);
563
+ return envelope.ok ? 0 : 1;
555
564
  } catch (error) {
556
565
  const format = inferApiFormat(args, parsed);
557
566
  const tips = isMissingTargetError(error)
@@ -566,7 +575,6 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
566
575
  // missing `--input` file) instead of falling back to plain text.
567
576
  const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
568
577
  console.error(
569
- // codeql[js/clear-text-logging] CLI catch-block error render. No secret logged: envelope stores only passwordProvided (boolean) and target.input (router, not the password).
570
578
  renderApiEnvelope(envelope, format, {
571
579
  raw: true,
572
580
  verbose: parsed.verbose ?? false,
@@ -578,14 +586,12 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
578
586
  tips,
579
587
  );
580
588
  console.error(
581
- // codeql[js/clear-text-logging] CLI catch-block error render. No secret logged: envelope stores only passwordProvided (boolean) and target.input (router, not the password).
582
589
  renderApiEnvelope(envelope, format, {
583
590
  verbose: parsed?.verbose ?? false,
584
591
  }),
585
592
  );
586
593
  } else {
587
594
  console.error(
588
- // codeql[js/clear-text-logging] CLI catch-block error render. No secret logged: envelope stores only passwordProvided (boolean) and target.input (router, not the password).
589
595
  formatCentrsErrorText(
590
596
  asCentrsError(error, {
591
597
  code: "input/invalid-command",
@@ -659,7 +665,6 @@ async function runApiFanoutCli(
659
665
  Bun.env,
660
666
  );
661
667
  console.error(
662
- // codeql[js/clear-text-logging] Fan-out CLI catch-block error render — twin of #80/#81. No secret logged: the error envelope's request summary, selection and target carry no auth fields; serializeCentrsError holds only code/summary/remediation/context.
663
668
  renderApiFanoutEnvelope(envelope, format, {
664
669
  verbose: parsed.verbose ?? false,
665
670
  }),
@@ -679,8 +684,9 @@ function endpointInfersListen(endpoint: string): boolean {
679
684
 
680
685
  /**
681
686
  * Emit all structured stream outcomes on stdout. Any failure sets exit 1,
682
- * including a terminal error after successful rows. Raw mode retains its
683
- * stderr error contract; Ctrl-C requests bounded cancellation.
687
+ * including a terminal error after successful rows. Text puts only frame rows
688
+ * on stdout; raw mode retains its stderr error contract; Ctrl-C requests
689
+ * bounded cancellation.
684
690
  */
685
691
  async function runApiStreamCli(
686
692
  parsed: ApiCliArgs,
@@ -697,17 +703,25 @@ async function runApiStreamCli(
697
703
  Bun.env,
698
704
  controller.signal,
699
705
  )) {
700
- // `--raw` is bare payloads only; a notice has none.
701
- if (parsed.raw && envelope.meta.operation?.stream?.kind === "notice")
702
- continue;
706
+ const kind = envelope.ok
707
+ ? envelope.meta.operation?.stream?.kind
708
+ : undefined;
709
+ // `--raw` is bare payloads only; `started` has none.
710
+ if (parsed.raw && kind === "started") continue;
703
711
  const line = renderApiStreamLine(envelope, format, {
704
712
  raw: parsed.raw,
705
713
  verbose: parsed.verbose,
706
714
  });
707
- if (envelope.ok || !parsed.raw) {
715
+ if (parsed.raw) {
716
+ if (envelope.ok) console.log(line);
717
+ else console.error(line);
718
+ } else if (format !== "text" || kind === "frame") {
708
719
  console.log(line);
709
720
  } else {
710
- console.error(line);
721
+ // Text stdout is data (#470, #473): status lines, tips, warnings
722
+ // and errors go to stderr. `started` renders its own tips.
723
+ if (line) console.error(line);
724
+ printTextNotes({ warnings: envelope.warnings, tips: [] });
711
725
  }
712
726
  if (!envelope.ok) exitCode = 1;
713
727
  }