@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
@@ -17,15 +17,19 @@ import {
17
17
  buildRetrieveErrorEnvelopeFromResolved,
18
18
  executeRetrieve,
19
19
  type ResolvedRetrieveRequest,
20
- type RetrieveEnvelope,
21
20
  type RetrieveRequest,
22
21
  type RetrieveSampleStopReason,
23
22
  type RetrieveSampleSummary,
23
+ type RetrieveStreamLine,
24
24
  type RetrieveSuccessEnvelope,
25
25
  resolveRetrieveRequest,
26
26
  validateRetrieveRead,
27
27
  } from "./retrieve.ts";
28
- import { failedStreamEnvelope, streamEnvelope } from "./retrieve-stream.ts";
28
+ import {
29
+ failedStreamEnvelope,
30
+ streamEnvelope,
31
+ streamLineEnvelope,
32
+ } from "./retrieve-stream.ts";
29
33
 
30
34
  export interface RetrieveSampleOptions {
31
35
  /** Ctrl-C: ends the sample with `stopReason: "interrupted"`. */
@@ -33,16 +37,17 @@ export interface RetrieveSampleOptions {
33
37
  }
34
38
 
35
39
  /**
36
- * Sample one menu: yields a `sample` line per complete read, then exactly one
37
- * summary (successful, or an error envelope carrying the partial count).
38
- * Errors before the first read (resolution, validation) yield one error
39
- * envelope without a summary.
40
+ * Sample one menu: yields `started` (the full meta), a `sample` line per
41
+ * complete read (only its `meta.operation`), then exactly one summary
42
+ * (successful, or an error envelope carrying the partial count). Errors before
43
+ * the first read (resolution, validation) yield one error envelope without a
44
+ * summary.
40
45
  */
41
46
  export async function* retrieveSample(
42
47
  request: RetrieveRequest,
43
48
  env: Record<string, string | undefined> = Bun.env,
44
49
  options: RetrieveSampleOptions = {},
45
- ): AsyncGenerator<RetrieveEnvelope, void, void> {
50
+ ): AsyncGenerator<RetrieveStreamLine, void, void> {
46
51
  if (request.wait !== undefined) {
47
52
  yield buildRetrieveErrorEnvelope(
48
53
  request,
@@ -126,6 +131,7 @@ export async function* retrieveSample(
126
131
  sample.durationMs,
127
132
  );
128
133
  }
134
+ yield streamEnvelope(resolved, validation, null, { kind: "started" });
129
135
  try {
130
136
  while (stopReason === undefined) {
131
137
  const sentAt = Date.now();
@@ -141,7 +147,7 @@ export async function* retrieveSample(
141
147
  // A read still in flight at the stop is not a complete sample.
142
148
  if (result === undefined) break;
143
149
  samples += 1;
144
- yield streamEnvelope(resolved, validation, result.data, {
150
+ yield streamLineEnvelope(result.data, {
145
151
  kind: "sample",
146
152
  index: samples,
147
153
  at: new Date(sentAt).toISOString(),
@@ -2,7 +2,9 @@
2
2
  * Line helpers shared by the two streaming forms of `retrieve`: `--follow`
3
3
  * (`src/retrieve-follow.ts`) and `--sample` (`src/retrieve-sample.ts`). Each
4
4
  * line is one envelope whose `meta.operation.stream` says what it is
5
- * (`commands/retrieve/README.md` → Follow, Sample).
5
+ * (`commands/retrieve/README.md` → Follow, Sample). A stream opens with a
6
+ * full-meta `started` line and ends with a full-meta `summary`; the data lines
7
+ * between carry only `meta.operation` (#462, #473).
6
8
  */
7
9
 
8
10
  import type { EnvelopeValidationMeta, Tip } from "./core/envelope.ts";
@@ -11,15 +13,16 @@ import {
11
13
  buildRetrieveErrorEnvelopeFromResolved,
12
14
  metaFromResolved,
13
15
  type ResolvedRetrieveRequest,
14
- type RetrieveEnvelope,
15
16
  type RetrieveErrorEnvelope,
16
17
  type RetrieveOutputFormat,
18
+ type RetrieveStreamLine,
19
+ type RetrieveStreamLineEnvelope,
17
20
  type RetrieveStreamMeta,
18
21
  type RetrieveSuccessEnvelope,
19
22
  retrieveRequestSummary,
20
23
  } from "./retrieve.ts";
21
24
 
22
- /** One stream line's envelope: `data` plus its `meta.operation.stream` marker. */
25
+ /** A full-meta stream line (`started`, `synced`, `summary`): `data` plus its marker. */
23
26
  export function streamEnvelope(
24
27
  resolved: ResolvedRetrieveRequest,
25
28
  validation: EnvelopeValidationMeta,
@@ -45,6 +48,27 @@ export function streamEnvelope(
45
48
  };
46
49
  }
47
50
 
51
+ /** A `frame` or `sample` line: only its `meta.operation` (#462, #473). */
52
+ export function streamLineEnvelope(
53
+ data: unknown,
54
+ stream: RetrieveStreamMeta,
55
+ tips: Tip[] = [],
56
+ ): RetrieveStreamLineEnvelope {
57
+ return {
58
+ ok: true,
59
+ data,
60
+ warnings: [],
61
+ tips,
62
+ meta: {
63
+ operation: {
64
+ kind: "data",
65
+ objectCount: data === null ? 0 : Array.isArray(data) ? data.length : 1,
66
+ stream,
67
+ },
68
+ },
69
+ };
70
+ }
71
+
48
72
  /**
49
73
  * The failed summary that ends a stream after it started: the error, plus the
50
74
  * partial counts as a `summary` marker. `stopReason` is `routeros-error` when
@@ -77,17 +101,19 @@ export function failedStreamEnvelope(
77
101
  * One stream envelope as one line: compact NDJSON for every structured format
78
102
  * (a multi-line YAML document cannot be a stream line), and a short row for
79
103
  * `text`. Router text is JSON-escaped so it cannot carry terminal control
80
- * bytes.
104
+ * bytes. In text, only `frame` and `sample` rows are data
105
+ * ({@link isStreamDataLine}); the CLI writes the rest to stderr, and an empty
106
+ * string (a `started` line without tips) prints nothing.
81
107
  */
82
108
  export function renderRetrieveStreamLine(
83
- envelope: RetrieveEnvelope,
109
+ envelope: RetrieveStreamLine,
84
110
  format: RetrieveOutputFormat,
85
111
  ): string {
86
112
  if (format !== "text") return JSON.stringify(envelope);
87
113
  if (!envelope.ok) return formatCentrsErrorLine(envelope.error);
88
114
  const stream = envelope.meta.operation?.stream;
89
115
  switch (stream?.kind) {
90
- case "notice":
116
+ case "started":
91
117
  return envelope.tips
92
118
  .map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
93
119
  .join("\n");
@@ -110,6 +136,12 @@ export function renderRetrieveStreamLine(
110
136
  }
111
137
  }
112
138
 
139
+ /** Whether a stream line is a data row: text stdout carries only these (#473). */
140
+ export function isStreamDataLine(envelope: RetrieveStreamLine): boolean {
141
+ const kind = envelope.ok ? envelope.meta.operation?.stream?.kind : undefined;
142
+ return kind === "frame" || kind === "sample";
143
+ }
144
+
113
145
  function escaped(text: string): string {
114
146
  return JSON.stringify(text).slice(1, -1);
115
147
  }
package/src/retrieve.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  CommonSettingsMeta,
6
6
  SettingSource as CoreSettingSource,
7
7
  EnvelopeValidationMeta,
8
+ StreamLineEnvelope,
8
9
  ValidationTrace,
9
10
  } from "./core/envelope.ts";
10
11
  import { notReachedReason } from "./core/envelope.ts";
@@ -51,6 +52,7 @@ import {
51
52
  type ResolvedAuth,
52
53
  type ResolvedSetting,
53
54
  type ResolvedTarget,
55
+ redactTargetInput,
54
56
  resolveAuth,
55
57
  resolveBooleanSetting,
56
58
  resolveCdb,
@@ -230,15 +232,20 @@ export interface RetrieveSampleSummary {
230
232
  * (`commands/retrieve/README.md` → Follow, Sample). A `frame` is one row
231
233
  * change: `upsert` carries the latest full row, `removed` carries
232
234
  * `data: null`; `id` is the row identity even when the projection leaves `.id`
233
- * out of `data`. A `sample` is one complete read; `at` is when it was sent
235
+ * out of `data`. `observedAt` is when centrs received the reply the frame is
236
+ * built from (host clock, ISO 8601; a replayed bootstrap change keeps its
237
+ * arrival time). A `sample` is one complete read; `at` is when it was sent
234
238
  * (host clock) and `readMs` how long it took. The two summaries are told apart
235
- * by their counts (`frames` vs `samples`).
239
+ * by their counts (`frames` vs `samples`). `started` is the first line of
240
+ * every stream that got past validation: the full meta, `data: null`, and any
241
+ * advice in `tips` (#473).
236
242
  */
237
243
  export type RetrieveStreamMeta =
238
- | { kind: "notice" }
244
+ | { kind: "started" }
239
245
  | {
240
246
  kind: "frame";
241
247
  index: number;
248
+ observedAt: string;
242
249
  phase: "snapshot" | "live";
243
250
  change: "upsert" | "removed";
244
251
  id: string;
@@ -264,6 +271,23 @@ export interface RetrieveOperationMeta {
264
271
  }
265
272
 
266
273
  export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
274
+
275
+ /**
276
+ * A `--follow` frame or `--sample` sample line: only its own `meta.operation`
277
+ * (#462, #473). The rest of the meta is on the stream's `started` and
278
+ * `summary` lines (and `synced` for `--follow`).
279
+ */
280
+ export type RetrieveStreamLineEnvelope = StreamLineEnvelope<
281
+ unknown,
282
+ Pick<RetrieveOperationMeta, "kind" | "objectCount"> & {
283
+ stream: RetrieveStreamMeta;
284
+ }
285
+ >;
286
+
287
+ /** One `retrieve --follow` or `--sample` line: a full envelope or a data line. */
288
+ export type RetrieveStreamLine = RetrieveEnvelope | RetrieveStreamLineEnvelope;
289
+ /** One `retrieve --follow` line. */
290
+ export type RetrieveFollowLine = RetrieveStreamLine;
267
291
  export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
268
292
  unknown,
269
293
  RetrieveOperationMeta
@@ -352,7 +376,7 @@ export async function retrieve(
352
376
  summary:
353
377
  "`follow` yields a stream of envelopes instead of one `retrieve` result.",
354
378
  remediation:
355
- "Consume it with `retrieveFollow()` (library) or `centrs retrieve … --follow` (CLI): snapshot frames, `synced`, live frames, then a summary.",
379
+ "Consume it with `retrieveFollow()` (library) or `centrs retrieve … --follow` (CLI): `started`, snapshot frames, `synced`, live frames, then a summary.",
356
380
  context: { capability: "follow" },
357
381
  });
358
382
  }
@@ -362,7 +386,7 @@ export async function retrieve(
362
386
  summary:
363
387
  "`sample` yields a stream of envelopes instead of one `retrieve` result.",
364
388
  remediation:
365
- "Consume it with `retrieveSample()` (library) or `centrs retrieve … --sample <interval>` (CLI): one line per complete read, then a summary.",
389
+ "Consume it with `retrieveSample()` (library) or `centrs retrieve … --sample <interval>` (CLI): `started`, one line per complete read, then a summary.",
366
390
  context: { capability: "sample" },
367
391
  });
368
392
  }
@@ -547,7 +571,7 @@ export function buildRetrieveErrorEnvelope(
547
571
  warnings: [],
548
572
  tips: [],
549
573
  meta: {
550
- target: { input: request.targetInput },
574
+ target: { input: redactTargetInput(request.targetInput) },
551
575
  via: requestedVia,
552
576
  settings: {},
553
577
  },
@@ -653,7 +677,7 @@ export function renderRetrieveEnvelope(
653
677
  case "text":
654
678
  return (
655
679
  (envelope.ok
656
- ? renderRetrieveSuccessText(envelope, options)
680
+ ? renderRetrieveSuccessText(envelope)
657
681
  : formatCentrsErrorText(envelope.error, options)) +
658
682
  (envelope.meta.operation?.wait
659
683
  ? `\nwait: ${envelope.meta.operation.wait.stopReason}; ${envelope.meta.operation.wait.elapsedMs}ms; ${envelope.meta.operation.wait.attempts} attempt(s), ${envelope.meta.operation.wait.observations} observation(s)`
@@ -664,64 +688,42 @@ export function renderRetrieveEnvelope(
664
688
  }
665
689
  }
666
690
 
667
- function renderRetrieveSuccessText(
668
- envelope: RetrieveSuccessEnvelope,
669
- options: { verbose?: boolean },
670
- ): string {
671
- const lines: string[] = [];
672
- const verbose = options.verbose ?? false;
673
- const meta = envelope.meta;
674
- const operation = meta.operation;
675
-
676
- if (verbose) {
677
- const target = meta.target;
678
- lines.push(`target: ${target.input ?? target.host} -> ${target.baseUrl}`);
679
- lines.push(`via: ${meta.via}`);
680
- lines.push(
681
- `sources: via=${formatCoreSource(meta.settings.via)}, host=${formatCoreSource(
682
- meta.settings.host,
683
- )}, port=${formatCoreSource(meta.settings.port)}, timeout=${formatCoreSource(
684
- meta.settings.timeoutMs,
685
- )}, format=${formatCoreSource(meta.settings.format)}, validate=${formatCoreSource(
686
- meta.settings.validate,
687
- )}`,
688
- );
689
- if (meta.validation?.enabled) {
690
- lines.push(`validation: ${meta.validation.source}`);
691
- }
692
- lines.push("");
693
- }
694
-
695
- if (operation?.kind === "attributes") {
691
+ /** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
692
+ function renderRetrieveSuccessText(envelope: RetrieveSuccessEnvelope): string {
693
+ if (envelope.meta.operation?.kind === "attributes") {
696
694
  const attributes = Array.isArray(envelope.data)
697
695
  ? (envelope.data as readonly string[])
698
696
  : [];
699
- lines.push(...attributes);
700
- } else if (typeof envelope.data === "string") {
701
- lines.push(envelope.data);
702
- } else {
703
- lines.push(JSON.stringify(envelope.data, null, 2));
704
- }
705
-
706
- if (envelope.warnings.length > 0) {
707
- lines.push("");
708
- for (const warning of envelope.warnings) {
709
- lines.push(`warning [${warning.code}]: ${warning.message}`);
710
- }
697
+ return attributes.join("\n");
711
698
  }
699
+ return typeof envelope.data === "string"
700
+ ? envelope.data
701
+ : JSON.stringify(envelope.data, null, 2);
702
+ }
712
703
 
713
- if (envelope.tips.length > 0) {
714
- lines.push("");
715
- lines.push("Tips:");
716
- for (const item of envelope.tips) {
717
- lines.push(` - [${item.code}] ${item.message}`);
718
- if (item.fix) {
719
- lines.push(` fix: ${item.fix}`);
720
- }
721
- }
704
+ /**
705
+ * The `--verbose` header of a text-mode retrieve result (stderr, #470); none
706
+ * for a failure before resolution (`via: null`).
707
+ */
708
+ export function retrieveVerboseLines(envelope: RetrieveEnvelope): string[] {
709
+ const meta = envelope.meta;
710
+ if (meta.via === null) return [];
711
+ const target = meta.target;
712
+ const lines = [
713
+ `target: ${target.input ?? target.host}${target.baseUrl ? ` -> ${target.baseUrl}` : ""}`,
714
+ `via: ${meta.via}`,
715
+ `sources: via=${formatCoreSource(meta.settings.via)}, host=${formatCoreSource(
716
+ meta.settings.host,
717
+ )}, port=${formatCoreSource(meta.settings.port)}, timeout=${formatCoreSource(
718
+ meta.settings.timeoutMs,
719
+ )}, format=${formatCoreSource(meta.settings.format)}, validate=${formatCoreSource(
720
+ meta.settings.validate,
721
+ )}`,
722
+ ];
723
+ if (meta.validation?.enabled) {
724
+ lines.push(`validation: ${meta.validation.source}`);
722
725
  }
723
-
724
- return lines.join("\n");
726
+ return lines;
725
727
  }
726
728
 
727
729
  export function retrieveRequestSummary(
@@ -1445,6 +1447,18 @@ function resolveProtocol(
1445
1447
  return via as ResolvedSetting<RouterOsProtocol>;
1446
1448
  }
1447
1449
 
1450
+ /**
1451
+ * The output format a request resolves to (config < env < CLI; the CDB never
1452
+ * sets it). The `--follow` CLI needs it before the first line, because frame
1453
+ * lines carry no `meta.operation.request` (#462).
1454
+ */
1455
+ export async function resolveRetrieveOutputFormat(
1456
+ request: RetrieveRequest,
1457
+ env: Record<string, string | undefined> = Bun.env,
1458
+ ): Promise<RetrieveOutputFormat> {
1459
+ return resolveFormat(request, env, await loadEnvFileDefaults(env)).value;
1460
+ }
1461
+
1448
1462
  function resolveFormat(
1449
1463
  request: RetrieveRequest,
1450
1464
  env: Record<string, string | undefined>,
@@ -48,9 +48,11 @@ import {
48
48
  import { promptForWriteConfirmation } from "./execute.ts";
49
49
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
50
50
  import {
51
+ emptyWriteSelectionError,
51
52
  expandSelection,
52
53
  isDefaultRecordTarget,
53
54
  loadEnvFileDefaults,
55
+ redactTargetInput,
54
56
  type SelectionExpansion,
55
57
  type SelectionMember,
56
58
  type SelectionResolveInput,
@@ -95,6 +97,8 @@ export interface TransferFanoutOperationMeta {
95
97
  concurrency: number;
96
98
  summary: FanoutSummary;
97
99
  request: TransferFanoutRequestSummary;
100
+ /** Hoisted from the targets when every target used the same login (#461). */
101
+ auth?: { username?: string; passwordProvided: boolean };
98
102
  selection: TransferSelectionSummary;
99
103
  }
100
104
 
@@ -135,7 +139,7 @@ function summarizeSelection(
135
139
  where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
136
140
  all: selection.all,
137
141
  default: selection.default,
138
- positionals: selection.positionals,
142
+ positionals: selection.positionals.map((value) => redactTargetInput(value)),
139
143
  quickchr: selection.quickchr,
140
144
  };
141
145
  }
@@ -299,6 +303,9 @@ export async function transferFanout(
299
303
  const concurrency = resolveFanoutConcurrency(options.concurrency, "rest-api");
300
304
 
301
305
  if (expansion.empty) {
306
+ if (isTransferWriteVerb(request.verb)) {
307
+ throw emptyWriteSelectionError(expansion, `\`${request.verb}\``);
308
+ }
302
309
  return transferFanoutEnvelope({
303
310
  selection: selectionSummary,
304
311
  concurrency,
package/src/transfer.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  type ResolvedAuth,
61
61
  type ResolvedSetting,
62
62
  type ResolvedTarget,
63
+ redactTargetInput,
63
64
  resolveAuth,
64
65
  resolveBooleanSetting,
65
66
  resolveCdb,
@@ -1594,7 +1595,7 @@ export function buildTransferErrorEnvelope(
1594
1595
  warnings: [],
1595
1596
  tips: [],
1596
1597
  meta: {
1597
- target: { input: request.targetInput },
1598
+ target: { input: redactTargetInput(request.targetInput) },
1598
1599
  via: null,
1599
1600
  settings: {},
1600
1601
  },
@@ -1615,19 +1616,16 @@ export function renderTransferEnvelope(
1615
1616
  return toYaml(envelope);
1616
1617
  case "text":
1617
1618
  return envelope.ok
1618
- ? renderSuccessText(envelope, options)
1619
+ ? renderSuccessText(envelope)
1619
1620
  : formatCentrsErrorText(envelope.error, options);
1620
1621
  default:
1621
1622
  return exhaustiveFormat(format);
1622
1623
  }
1623
1624
  }
1624
1625
 
1625
- function renderSuccessText(
1626
- envelope: TransferSuccessEnvelope,
1627
- options: { verbose?: boolean },
1628
- ): string {
1626
+ /** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
1627
+ function renderSuccessText(envelope: TransferSuccessEnvelope): string {
1629
1628
  const lines: string[] = [];
1630
- const operation = envelope.meta.operation;
1631
1629
 
1632
1630
  if (Array.isArray(envelope.data)) {
1633
1631
  // list
@@ -1653,21 +1651,19 @@ function renderSuccessText(
1653
1651
  lines.push(parts.join(" "));
1654
1652
  }
1655
1653
 
1656
- if (options.verbose && operation) {
1657
- lines.push("");
1658
- lines.push(`via: ${envelope.meta.via}`);
1659
- }
1660
-
1661
- if (envelope.warnings.length > 0) {
1662
- lines.push("");
1663
- for (const warning of envelope.warnings) {
1664
- lines.push(`warning [${warning.code}]: ${warning.message}`);
1665
- }
1666
- }
1667
-
1668
1654
  return lines.join("\n");
1669
1655
  }
1670
1656
 
1657
+ /**
1658
+ * The `--verbose` header of a text-mode transfer result (stderr, #470); none
1659
+ * for a failure before resolution.
1660
+ */
1661
+ export function transferVerboseLines(envelope: TransferEnvelope): string[] {
1662
+ return envelope.meta.operation && envelope.meta.via !== null
1663
+ ? [`via: ${envelope.meta.via}`]
1664
+ : [];
1665
+ }
1666
+
1671
1667
  // ── Small helpers ────────────────────────────────────────────────────────────
1672
1668
 
1673
1669
  function uploadSizeHint(request: TransferRequest): number | undefined {