@tikoci/centrs 0.1.7 → 0.1.8

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 (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. package/src/transfer.ts +234 -32
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { CentrsError } from "../errors.ts";
18
+ import { formatEndpoint } from "./endpoint.ts";
18
19
 
19
20
  export interface SshConnectionConfig {
20
21
  host: string;
@@ -54,7 +55,8 @@ export function sshCommonOptions(config: SshConnectionConfig): string[] {
54
55
  const connectSeconds = Math.max(1, Math.ceil(config.timeoutMs / 1000));
55
56
  const options: string[] = [];
56
57
  if (config.sshKey) {
57
- options.push("-i", config.sshKey);
58
+ // Exclude unrelated agent keys; explicit IdentityFile config still applies.
59
+ options.push("-i", config.sshKey, "-o", "IdentitiesOnly=yes");
58
60
  }
59
61
  // Batch clients (sftp/execute) keep BatchMode so a missing/encrypted credential
60
62
  // errors out rather than blocking on a prompt; the interactive terminal relay
@@ -79,6 +81,36 @@ export function sshCommonOptions(config: SshConnectionConfig): string[] {
79
81
  return options;
80
82
  }
81
83
 
84
+ /**
85
+ * OpenSSH's own pre-login refusals. A bare `Permission denied` is not one: sftp
86
+ * prints it after login when RouterOS refuses an operation (`dest open "/x":
87
+ * Permission denied` — CHR 7.24.5, a user without `write` policy), and that is
88
+ * a policy refusal, not an authentication failure (#391).
89
+ */
90
+ export function isSshAuthFailure(lowerStderr: string): boolean {
91
+ return (
92
+ /permission denied \(/.test(lowerStderr) ||
93
+ lowerStderr.includes("authentication failed") ||
94
+ lowerStderr.includes("no such identity") ||
95
+ lowerStderr.includes("too many authentication failures")
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Remediation for an `auth/failed` from a batch (`BatchMode=yes`) client —
101
+ * `execute --via ssh` and sftp `transfer`. A batch client never sends a
102
+ * password, so it names the key source it used and leaves RouterOS's password
103
+ * policy as a separate fact rather than a guessed cause. A user without `ssh`
104
+ * policy is refused at login (`Permission denied (publickey)`); CHR controls in
105
+ * `test/integration/ssh-auth-policy.test.ts` (#391).
106
+ */
107
+ export function batchSshAuthRemediation(sshKeySelected: boolean): string {
108
+ const keys = sshKeySelected
109
+ ? "the selected SSH key (plus any `IdentityFile` in the host OpenSSH configuration)"
110
+ : "keys from the host OpenSSH configuration or agent";
111
+ return `Batch SSH never sends a password; it authenticates with ${keys}. Install the matching public key for the user on the device (\`/user/ssh-keys import\`; \`/user/ssh-keys print\` lists the user's keys) and confirm the user's group has the \`ssh\` policy. Password login is separate: on RouterOS 7.21+, \`/ip/ssh password-authentication\` (default \`yes-if-no-key\`) refuses it for a user who has a key.`;
112
+ }
113
+
82
114
  /** `[user@]host` for the ssh/sftp command line. */
83
115
  export function sshUserHost(config: SshConnectionConfig): string {
84
116
  return `${config.username ? `${config.username}@` : ""}${config.host}`;
@@ -94,7 +126,7 @@ export function sshUserHost(config: SshConnectionConfig): string {
94
126
  export function mapSshConnectError(
95
127
  stderr: string,
96
128
  exitCode: number,
97
- ctx: { host: string; port: number; op: string },
129
+ ctx: { host: string; port: number; op: string; sshKeySelected?: boolean },
98
130
  ): CentrsError {
99
131
  const { host, port, op } = ctx;
100
132
  const trimmed = stderr.trim();
@@ -111,25 +143,20 @@ export function mapSshConnectError(
111
143
  causeData: trimmed,
112
144
  });
113
145
  }
114
- if (
115
- lower.includes("permission denied") ||
116
- lower.includes("authentication failed") ||
117
- lower.includes("no such identity") ||
118
- lower.includes("too many authentication failures")
119
- ) {
146
+ if (isSshAuthFailure(lower)) {
147
+ const sshKeySelected = ctx.sshKeySelected ?? false;
120
148
  return new CentrsError({
121
149
  code: "auth/failed",
122
150
  summary: `SSH authentication to ${host} failed.`,
123
- remediation:
124
- "Provide a key the device trusts via `--ssh-key` / the ssh-agent, and confirm the RouterOS user has `ssh` policy. RouterOS refuses password login once an SSH key is set for the user.",
125
- context,
151
+ remediation: batchSshAuthRemediation(sshKeySelected),
152
+ context: { ...context, sshKeySelected },
126
153
  causeData: trimmed,
127
154
  });
128
155
  }
129
156
  if (lower.includes("connection refused")) {
130
157
  return new CentrsError({
131
158
  code: "transport/connection-refused",
132
- summary: `SSH connection to ${host}:${port} was refused.`,
159
+ summary: `SSH connection to ${formatEndpoint(host, port)} was refused.`,
133
160
  remediation:
134
161
  "Enable the RouterOS SSH service (`/ip service enable ssh`) and confirm the port / firewall.",
135
162
  context,
@@ -143,7 +170,7 @@ export function mapSshConnectError(
143
170
  ) {
144
171
  return new CentrsError({
145
172
  code: "transport/timeout",
146
- summary: `SSH connection to ${host}:${port} timed out.`,
173
+ summary: `SSH connection to ${formatEndpoint(host, port)} timed out.`,
147
174
  remediation:
148
175
  "Raise `--timeout`, or confirm the host and port are reachable.",
149
176
  context,
@@ -166,7 +193,7 @@ export function mapSshConnectError(
166
193
  }
167
194
  return new CentrsError({
168
195
  code: "transport/network",
169
- summary: `SSH ${op} failed against ${host}:${port} (exit ${exitCode}).`,
196
+ summary: `SSH ${op} failed against ${formatEndpoint(host, port)} (exit ${exitCode}).`,
170
197
  remediation:
171
198
  "Re-run with `--format json` for the structured cause, and confirm SSH access (`/ip service`) to the device.",
172
199
  context,
@@ -244,6 +271,7 @@ export class SshExecClient {
244
271
  host: this.config.host,
245
272
  port: this.config.port,
246
273
  op: "execute",
274
+ sshKeySelected: Boolean(this.config.sshKey),
247
275
  });
248
276
  }
249
277
  return cleanConsoleOutput(result.stdout);
@@ -26,6 +26,7 @@
26
26
  * (WP-2c / WP-1b); the shape below leaves a clean seam for them.
27
27
  */
28
28
 
29
+ import { isIP } from "node:net";
29
30
  import type { WinBoxCdbEntry } from "../data/winbox-cdb.ts";
30
31
  import {
31
32
  type DevicesWarning,
@@ -37,7 +38,7 @@ import {
37
38
  import { CentrsError } from "../errors.ts";
38
39
  import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
39
40
  import { parseCommentKv } from "./comment-kv.ts";
40
- import { normalizeMac } from "./mac.ts";
41
+ import { isMacAddress, normalizeMac } from "./mac.ts";
41
42
  import {
42
43
  parseBoolean,
43
44
  parseDuration,
@@ -146,9 +147,11 @@ function warningFromDevices(warning: DevicesWarning): ResolverWarning {
146
147
  *
147
148
  * Returns `undefined` when no CDB is in play (no explicit CDB inputs and the
148
149
  * default file is absent, or the default CDB has no matching record) — the
149
- * caller then treats `<router>` as a literal host. When an explicit CDB is
150
- * provided, a missing file/record surfaces as the underlying CDB error so the
151
- * "name not found without credentials" contract stays intact upstream.
150
+ * caller then treats `<router>` as a literal host. An address-shaped
151
+ * `<router>` ({@link isLiteralHostTarget}) that misses an explicit CDB is also a
152
+ * literal host. Otherwise, with an explicit CDB, a missing file/record surfaces
153
+ * as the underlying CDB error so the "name not found without credentials"
154
+ * contract stays intact upstream.
152
155
  */
153
156
  export async function resolveCdb(
154
157
  input: CdbResolveInput,
@@ -227,8 +230,9 @@ export async function resolveCdb(
227
230
  // target has no record of its own. On the CLI/API this fills creds for
228
231
  // an ad-hoc target; on MCP the allowlist gate upstream still rejects
229
232
  // unregistered targets, so `__default__` never widens it. Without a
230
- // `__default__`, preserve the prior contract: undefined for an implicit
231
- // CDB (treat `<router>` as a literal host), rethrow for an explicit one.
233
+ // `__default__`: undefined (treat `<router>` as a literal host) for an
234
+ // implicit CDB or an address-shaped target; rethrow a name miss in an
235
+ // explicit CDB, since a name can't be told apart from a typo (#427).
232
236
  if (defaults) {
233
237
  return {
234
238
  target: input.targetInput,
@@ -242,7 +246,7 @@ export async function resolveCdb(
242
246
  defaults,
243
247
  };
244
248
  }
245
- if (!explicitCdb) {
249
+ if (!explicitCdb || isLiteralHostTarget(input.targetInput)) {
246
250
  return undefined;
247
251
  }
248
252
  }
@@ -250,6 +254,23 @@ export async function resolveCdb(
250
254
  }
251
255
  }
252
256
 
257
+ /**
258
+ * True when `<router>` is an address rather than a name: an IPv4 or IPv6 literal
259
+ * (with an optional `:port`, IPv6 as `[…]:port`), an `http(s)://` URL, or a MAC.
260
+ * A miss on one of these in an explicit CDB can't be a mistyped record name, so
261
+ * {@link resolveCdb} lets it through as a literal host. A DNS name or identity
262
+ * keeps failing closed with `cdb/not-found-target`.
263
+ */
264
+ export function isLiteralHostTarget(value: string): boolean {
265
+ const trimmed = value.trim();
266
+ if (isMacAddress(trimmed) || /^https?:\/\//i.test(trimmed)) {
267
+ return true;
268
+ }
269
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(trimmed)?.[1];
270
+ const host = bracketed ?? trimmed.replace(/^([^:]+):\d+$/, "$1");
271
+ return isIP(host) !== 0;
272
+ }
273
+
253
274
  /** A single CDB group member resolved for fanout. */
254
275
  export interface CdbGroupTarget {
255
276
  resolution: CdbResolution;
@@ -29,6 +29,7 @@ export {
29
29
  DEFAULT_RECORD_TARGET,
30
30
  expandCdbGroup,
31
31
  identityFromComment,
32
+ isLiteralHostTarget,
32
33
  macFromComment,
33
34
  type ResolverWarning,
34
35
  resolutionFromEntry,
@@ -14,6 +14,7 @@
14
14
  * a future `mac` field.
15
15
  */
16
16
 
17
+ import { isIP } from "node:net";
17
18
  import { CentrsError } from "../errors.ts";
18
19
  import type { RouterOsProtocol } from "../protocols/index.ts";
19
20
  import { MAC_TELNET_PORT } from "../protocols/mac-telnet.ts";
@@ -244,17 +245,22 @@ export function resolveTarget(
244
245
  // SSH (sftp/scp/exec) addresses host:22 and carries its own crypto, so the
245
246
  // URL scheme is irrelevant. An explicit `--port` / URL port wins over 22.
246
247
  tls = false;
247
- port =
248
- portSetting?.value ??
249
- (parsedUrl.port.length > 0 ? Number.parseInt(parsedUrl.port, 10) : 22);
248
+ port = portSetting?.value ?? typedPort(candidate, parsedUrl) ?? 22;
250
249
  baseUrl = `ssh://${formatHostForUrl(parsedUrl.hostname)}:${port}`;
251
250
  } else if (isNative) {
252
251
  // Native API ignores the URL scheme for its wire protocol; it defaults to
253
252
  // TCP 8728, or TLS (api-ssl) 8729 when the caller passed `https://` or an
254
253
  // explicit 8729. An explicit well-known port wins over the scheme so
255
254
  // `https://host --port 8728` stays plaintext instead of running a TLS
256
- // handshake against the plaintext API port.
257
- const explicitPort = portSetting?.value;
255
+ // handshake against the plaintext API port. A port the caller typed in
256
+ // the host (`host:9`, `http://host:9`) counts as explicit, as it does for
257
+ // ssh and REST (#426); a matched CDB record's port may be WinBox's, so it
258
+ // is left alone.
259
+ const explicitPort =
260
+ portSetting?.value ??
261
+ (hostFromCdbRecord(hostSetting, cdb)
262
+ ? undefined
263
+ : typedPort(candidate, parsedUrl));
258
264
  if (explicitPort === NATIVE_API_PORT) {
259
265
  tls = false;
260
266
  } else if (explicitPort === NATIVE_API_TLS_PORT) {
@@ -285,7 +291,9 @@ export function resolveTarget(
285
291
 
286
292
  return {
287
293
  input: input.targetInput,
288
- host: parsedUrl.hostname,
294
+ // WHATWG keeps an IPv6 hostname bracketed; sockets and ssh need the bare
295
+ // address (`baseUrl` re-brackets it).
296
+ host: parsedUrl.hostname.replace(/^\[(.*)\]$/, "$1"),
289
297
  port,
290
298
  scheme,
291
299
  tls,
@@ -299,6 +307,42 @@ export function resolveTarget(
299
307
  };
300
308
  }
301
309
 
310
+ /**
311
+ * The port written in the host candidate, if any. WHATWG `URL` drops a
312
+ * scheme-default port (`http://h:80`, `https://h:443`, and a bare `h:80`, which
313
+ * parses as http), so an empty `URL.port` is re-read from the text. The URL has
314
+ * already parsed, so a trailing `:digits` can only be that port, except in a
315
+ * bare IPv6 literal (`2001:db8::1`), which carries no port.
316
+ */
317
+ function typedPort(candidate: string, parsedUrl: URL): number | undefined {
318
+ if (parsedUrl.port.length > 0) {
319
+ return Number.parseInt(parsedUrl.port, 10);
320
+ }
321
+ if (isIP(candidate) === 6) {
322
+ return undefined;
323
+ }
324
+ const authority = candidate.replace(/^https?:\/\//i, "").replace(/\/$/, "");
325
+ const match = /:(\d+)$/.exec(authority);
326
+ return match?.[1] !== undefined ? Number.parseInt(match[1], 10) : undefined;
327
+ }
328
+
329
+ /**
330
+ * True when the host is a matched CDB record's `target` rather than something
331
+ * the caller typed (`--host`, `CENTRS_HOST`, config, or the positional itself).
332
+ * A `__default__`-only resolution carries the positional as its target, so its
333
+ * record index is the default record's.
334
+ */
335
+ function hostFromCdbRecord(
336
+ hostSetting: ResolvedSetting<string>,
337
+ cdb: CdbResolution | undefined,
338
+ ): boolean {
339
+ return (
340
+ cdb !== undefined &&
341
+ hostSetting.source.kind === "target-input" &&
342
+ cdb.recordIndex !== cdb.defaults?.recordIndex
343
+ );
344
+ }
345
+
302
346
  /**
303
347
  * Port provenance. When a port came from the settings ladder
304
348
  * (cli/env/comment-kv) its own source wins. Otherwise the port mirrors the
@@ -429,9 +473,11 @@ export function resolveAuth(
429
473
 
430
474
  export function parseHostCandidate(value: string): URL {
431
475
  const hasScheme = /^https?:\/\//i.test(value);
476
+ // A bare IPv6 literal (`2001:db8::1`) has no port; URL needs it bracketed.
477
+ const authority = isIP(value) === 6 ? `[${value}]` : value;
432
478
  let url: URL;
433
479
  try {
434
- url = new URL(hasScheme ? value : `http://${value}`);
480
+ url = new URL(hasScheme ? value : `http://${authority}`);
435
481
  } catch (cause) {
436
482
  throw invalidTargetError(value, cause);
437
483
  }
@@ -36,6 +36,7 @@ import type {
36
36
  CentrsSuccessEnvelope,
37
37
  FanoutData,
38
38
  FanoutSummary,
39
+ ValidationTrace,
39
40
  } from "./core/envelope.ts";
40
41
  import {
41
42
  buildFanoutEnvelope,
@@ -47,7 +48,12 @@ import {
47
48
  summarizeFanout,
48
49
  } from "./core/fanout.ts";
49
50
  import { toYaml } from "./core/yaml.ts";
50
- import { CentrsError, serializeCentrsError } from "./errors.ts";
51
+ import {
52
+ CentrsError,
53
+ formatCentrsErrorText,
54
+ serializeCentrsError,
55
+ terminalSafeLine,
56
+ } from "./errors.ts";
51
57
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
52
58
  import {
53
59
  expandSelection,
@@ -104,9 +110,13 @@ export type RetrieveFanoutErrorEnvelope =
104
110
 
105
111
  /** Test/override seams; production callers pass nothing. */
106
112
  export interface RetrieveFanoutInternals {
107
- /** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
113
+ /**
114
+ * Per-target executor (attempts; throws on failure). Defaults to the live
115
+ * tail, which records the settled gate in `trace` before reading.
116
+ */
108
117
  execute?: (
109
118
  resolved: ResolvedRetrieveRequest,
119
+ trace: ValidationTrace,
110
120
  ) => Promise<RetrieveSuccessEnvelope>;
111
121
  /** Selection expansion. Defaults to the real CDB-backed expansion. */
112
122
  expand?: (
@@ -190,6 +200,24 @@ export async function retrieveFanout(
190
200
  internals: RetrieveFanoutInternals = {},
191
201
  options: RetrieveFanoutOptions = {},
192
202
  ): Promise<RetrieveFanoutEnvelope> {
203
+ if (request.wait !== undefined)
204
+ throw new CentrsError({
205
+ code: "usage/fanout-not-supported",
206
+ summary: "`retrieve --wait` waits on one router and cannot fan out.",
207
+ remediation:
208
+ "Call `retrieveWait()` once per router with its own deadline.",
209
+ });
210
+ if (request.follow || request.sample !== undefined) {
211
+ const [flag, fn, capability] = request.follow
212
+ ? ["--follow", "retrieveFollow", "follow"]
213
+ : ["--sample", "retrieveSample", "sample"];
214
+ throw new CentrsError({
215
+ code: "usage/fanout-not-supported",
216
+ summary: `\`retrieve ${flag}\` watches one router and cannot fan out across multiple targets.`,
217
+ remediation: `Call \`${fn}()\` once per router to watch several.`,
218
+ context: { capability },
219
+ });
220
+ }
193
221
  const attributeSelections = validateRetrieveRequestShape(request);
194
222
  const config = await loadEnvFileDefaults(env);
195
223
  const global = resolveRetrieveGlobalContext(
@@ -237,6 +265,10 @@ export async function retrieveFanout(
237
265
  }
238
266
 
239
267
  const execute = internals.execute ?? runResolvedRetrieve;
268
+ // Per-target validation trace, keyed by the resolved request that both
269
+ // callbacks share, so a target that passed the gate and then failed reports
270
+ // the gate that ran (GH#435).
271
+ const traces = new WeakMap<ResolvedRetrieveRequest, ValidationTrace>();
240
272
  const sleep = internals.sleep ?? defaultFanoutSleep;
241
273
 
242
274
  const targets = await runFanout<
@@ -295,11 +327,18 @@ export async function retrieveFanout(
295
327
  warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
296
328
  summary: "Failed to resolve a fanout target.",
297
329
  }),
298
- execute: async (resolved, member) =>
299
- relabelAdhocMember(await execute(resolved), member),
330
+ execute: async (resolved, member) => {
331
+ const trace: ValidationTrace = {};
332
+ traces.set(resolved, trace);
333
+ return relabelAdhocMember(await execute(resolved, trace), member);
334
+ },
300
335
  onExecuteError: (resolved, member, error) =>
301
336
  relabelAdhocMember(
302
- buildRetrieveErrorEnvelopeFromResolved(resolved, error),
337
+ buildRetrieveErrorEnvelopeFromResolved(
338
+ resolved,
339
+ error,
340
+ traces.get(resolved),
341
+ ),
303
342
  member,
304
343
  ),
305
344
  sleep,
@@ -458,6 +497,8 @@ export function renderRetrieveFanoutEnvelope(
458
497
  return JSON.stringify(envelope, null, 2);
459
498
  case "yaml":
460
499
  return toYaml(envelope);
500
+ case "ndjson":
501
+ return JSON.stringify(envelope);
461
502
  default:
462
503
  return renderFanoutText(envelope, options);
463
504
  }
@@ -468,11 +509,7 @@ function renderFanoutText(
468
509
  options: { verbose?: boolean },
469
510
  ): string {
470
511
  if (!envelope.ok) {
471
- const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
472
- if (envelope.error.remediation) {
473
- lines.push(`Fix: ${envelope.error.remediation}`);
474
- }
475
- return lines.join("\n");
512
+ return formatCentrsErrorText(envelope.error, options);
476
513
  }
477
514
 
478
515
  const operation = envelope.meta.operation;
@@ -495,11 +532,13 @@ function renderFanoutText(
495
532
  );
496
533
  } else {
497
534
  lines.push(
498
- ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
535
+ terminalSafeLine(
536
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
537
+ ),
499
538
  );
500
539
  }
501
540
  if (options.verbose && !target.ok && target.error.remediation) {
502
- lines.push(` Fix: ${target.error.remediation}`);
541
+ lines.push(terminalSafeLine(` Fix: ${target.error.remediation}`));
503
542
  }
504
543
  }
505
544