@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/cli/common.ts CHANGED
@@ -6,7 +6,51 @@
6
6
  * together.
7
7
  */
8
8
 
9
- import { CentrsError } from "../errors.ts";
9
+ import type { Tip, Warning } from "../core/envelope.ts";
10
+ import { CentrsError, terminalSafeLine } from "../errors.ts";
11
+ import { redactTargetInput } from "../resolver/target.ts";
12
+ import { formatTipsText } from "./missing-target.ts";
13
+
14
+ /**
15
+ * Warnings that a single-target text-mode CLI leaves out (#470).
16
+ * `transport/provider-trust` rides every quickchr run over TLS or SSH, so
17
+ * printing it would be noise. `--json` keeps every warning.
18
+ */
19
+ const TEXT_QUIET_WARNING_CODES: readonly string[] = [
20
+ "transport/provider-trust",
21
+ ];
22
+
23
+ /**
24
+ * One `warning [<code>]: <message>` line per envelope warning, in the same form
25
+ * as fan-out text, minus {@link TEXT_QUIET_WARNING_CODES}.
26
+ */
27
+ export function textWarningLines(warnings: readonly Warning[]): string[] {
28
+ return warnings
29
+ .filter((warning) => !TEXT_QUIET_WARNING_CODES.includes(warning.code))
30
+ .map((warning) =>
31
+ terminalSafeLine(`warning [${warning.code}]: ${warning.message}`),
32
+ );
33
+ }
34
+
35
+ /**
36
+ * #470: in text mode a single-target command's stdout is its data and nothing
37
+ * else. The notes around the data go to stderr: the `--verbose` header before
38
+ * it ({@link printTextHeader}), then warnings and tips after it. This applies
39
+ * on success and on failure, so a write whose outcome is unknown still says so.
40
+ */
41
+ export function printTextNotes(envelope: {
42
+ warnings: readonly Warning[];
43
+ tips: readonly Tip[];
44
+ }): void {
45
+ for (const line of textWarningLines(envelope.warnings)) console.error(line);
46
+ const tips = formatTipsText(envelope.tips);
47
+ if (tips) console.error(tips);
48
+ }
49
+
50
+ /** The `--verbose` header of a text-mode result, on stderr (#470). */
51
+ export function printTextHeader(lines: readonly string[]): void {
52
+ if (lines.length > 0) console.error(lines.join("\n"));
53
+ }
10
54
 
11
55
  export interface CliCommandOption {
12
56
  flag: string;
@@ -106,6 +150,141 @@ export function unknownFlagError(
106
150
  });
107
151
  }
108
152
 
153
+ /** Single-quote a token for a shell line a user can copy, unless it is plain. */
154
+ function shellQuote(token: string): string {
155
+ return /^[\w/:=.,@%+-]+$/.test(token)
156
+ ? token
157
+ : `'${token.replaceAll("'", `'\\''`)}'`;
158
+ }
159
+
160
+ /**
161
+ * A centrs flag written after a bare `--` (#459).
162
+ *
163
+ * `--` makes every later token RouterOS input, so a trailing `--json` (the
164
+ * habitual position: `execute r1 -- '/cmd' --json`) went to the router and
165
+ * failed there as `unknown-attribute`. The RouterOS command is one quoted
166
+ * argument in every documented form, so a later token that is exactly one of
167
+ * this command's LONG flags (or its `--flag=value` spelling) is this mistake.
168
+ * Short aliases (`-u`, `-h`) are left alone: a lone `-x` is plausible
169
+ * RouterOS input, `--json` is not. A command that really needs such a token
170
+ * can carry it inside the quoted command.
171
+ *
172
+ * Throws `usage/flag-after-separator` with the command line rewritten with the
173
+ * flags (and their values) moved before `--`. Password values are never
174
+ * echoed. A no-op when there is no `--`.
175
+ */
176
+ export function rejectFlagsAfterSeparator(
177
+ command: string,
178
+ args: readonly string[],
179
+ options: readonly CliCommandOption[],
180
+ ): void {
181
+ const optionByFlag = new Map<string, CliCommandOption>();
182
+ for (const option of options) {
183
+ for (const token of option.flag.split("/")) {
184
+ const trimmed = token.trim();
185
+ if (trimmed.startsWith("-") && trimmed !== "--") {
186
+ optionByFlag.set(trimmed, option);
187
+ }
188
+ }
189
+ }
190
+ let separator = -1;
191
+ for (let index = 0; index < args.length; index += 1) {
192
+ const token = args[index] ?? "";
193
+ if (token === "--") {
194
+ separator = index;
195
+ break;
196
+ }
197
+ // A flag's value may itself be `--`; only the parser's separator counts.
198
+ if (optionByFlag.get(token)?.valueName !== undefined) index += 1;
199
+ }
200
+ if (separator === -1) return;
201
+ const secret = (flag: string) =>
202
+ flag.startsWith("-") && flag.includes("password");
203
+ const before: string[] = [];
204
+ for (let index = 0; index < separator; index += 1) {
205
+ const token = args[index] ?? "";
206
+ const [name = ""] = token.split("=");
207
+ const equal = token.indexOf("=");
208
+ if (equal >= 0 && optionByFlag.get(name)?.valueName !== undefined) {
209
+ before.push(
210
+ name,
211
+ secret(name) ? "<password>" : redactTargetInput(token.slice(equal + 1)),
212
+ );
213
+ continue;
214
+ }
215
+ if (equal >= 0 && secret(name)) {
216
+ before.push(`${name}=<password>`);
217
+ continue;
218
+ }
219
+ before.push(redactTargetInput(token));
220
+ if (optionByFlag.get(name)?.valueName !== undefined || secret(name)) {
221
+ const value = args[index + 1];
222
+ if (index + 1 < separator && value !== undefined) {
223
+ before.push(secret(name) ? "<password>" : redactTargetInput(value));
224
+ index += 1;
225
+ }
226
+ }
227
+ }
228
+ const unsupportedInlineBooleans: string[] = [];
229
+ const moved: string[] = [];
230
+ const kept: string[] = [];
231
+ const flags: string[] = [];
232
+ const after = args.slice(separator + 1);
233
+ for (let index = 0; index < after.length; index += 1) {
234
+ const token = after[index] ?? "";
235
+ const [name = "", ...valueParts] = token.split("=");
236
+ const option = name.startsWith("--") ? optionByFlag.get(name) : undefined;
237
+ if (option === undefined) {
238
+ kept.push(token);
239
+ continue;
240
+ }
241
+ flags.push(name);
242
+ // The parsers take a value flag's value as the next token (`--timeout 30s`,
243
+ // never `--timeout=30s`), so the fixed line splits it; only a boolean's
244
+ // supported `=` form (`--validate=no`) is kept as typed.
245
+ if (valueParts.length > 0) {
246
+ if (option.valueName === undefined) {
247
+ if (command !== "execute" || name !== "--validate") {
248
+ unsupportedInlineBooleans.push(name);
249
+ }
250
+ moved.push(token);
251
+ } else {
252
+ moved.push(name, secret(name) ? "<password>" : valueParts.join("="));
253
+ }
254
+ continue;
255
+ }
256
+ moved.push(name);
257
+ const value = after[index + 1];
258
+ if (option.valueName !== undefined && value !== undefined) {
259
+ moved.push(secret(name) ? "<password>" : value);
260
+ index += 1;
261
+ }
262
+ }
263
+ if (flags.length === 0) return;
264
+ // A moved --host value needs the same redaction as a positional target.
265
+ // Keep the RouterOS command itself verbatim; it is not a target.
266
+ const fixed = [
267
+ "centrs",
268
+ command,
269
+ ...before,
270
+ ...moved.map((token) => redactTargetInput(token)),
271
+ "--",
272
+ ...kept,
273
+ ]
274
+ .map(shellQuote)
275
+ .join(" ");
276
+ const list = flags.map((flag) => `\`${flag}\``).join(", ");
277
+ throw new CentrsError({
278
+ code: "usage/flag-after-separator",
279
+ summary: `${list} ${flags.length === 1 ? "comes" : "come"} after \`--\`, so ${flags.length === 1 ? "it" : "they"} would be sent to RouterOS as part of the command.`,
280
+ remediation:
281
+ unsupportedInlineBooleans.length > 0
282
+ ? `Put centrs flags before \`--\`. These flags accept no value: ${unsupportedInlineBooleans.map((flag) => `\`${flag}\``).join(", ")}. Use a bare flag to enable it, or omit it to keep the default.`
283
+ : `Put centrs flags before \`--\`: \`${fixed}\``,
284
+ context: { flags },
285
+ });
286
+ }
287
+
109
288
  export function renderCommandHelp(
110
289
  describe: string,
111
290
  command: CliCommandMetadata,
@@ -746,10 +746,8 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
746
746
  centrsError,
747
747
  );
748
748
  if (format === "json" || format === "yaml") {
749
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
750
749
  console.error(renderDevicesEnvelope(envelope, format));
751
750
  } else {
752
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
753
751
  console.error(formatCentrsErrorText(centrsError));
754
752
  }
755
753
  return 1;
@@ -207,7 +207,6 @@ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
207
207
  remediation:
208
208
  "Use `centrs discover --help` to inspect the supported flags.",
209
209
  });
210
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
211
210
  console.error(formatCentrsErrorText(centrsError));
212
211
  return 1;
213
212
  }
@@ -10,6 +10,7 @@ import {
10
10
  type ExecuteRequest,
11
11
  executeEnvelope,
12
12
  executeOutputFormats,
13
+ executeVerboseLines,
13
14
  renderExecuteEnvelope,
14
15
  } from "../execute.ts";
15
16
  import {
@@ -27,6 +28,9 @@ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
27
28
  import {
28
29
  type CliCommandMetadata,
29
30
  expectValue,
31
+ printTextHeader,
32
+ printTextNotes,
33
+ rejectFlagsAfterSeparator,
30
34
  renderCommandHelp,
31
35
  unknownFlagError,
32
36
  } from "./common.ts";
@@ -155,12 +159,12 @@ export const executeCommand: CliCommandMetadata = {
155
159
  },
156
160
  {
157
161
  flag: "--verbose",
158
- description: "Include additional context in text output.",
162
+ description: "Include additional context in text output, on stderr.",
159
163
  },
160
164
  {
161
165
  flag: "--",
162
166
  description:
163
- "End centrs option parsing: every following token is the literal RouterOS command, even flag-shaped ones (e.g. `-- /interface print where disabled=yes`).",
167
+ "End centrs option parsing: every following token is the literal RouterOS command (e.g. `-- /interface print where disabled=yes`). Put centrs flags before it; one of execute's own long flags after it is refused as `usage/flag-after-separator`.",
164
168
  },
165
169
  ],
166
170
  };
@@ -175,6 +179,7 @@ interface ExecuteCliArgs extends ExecuteRequest {
175
179
  }
176
180
 
177
181
  export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
182
+ rejectFlagsAfterSeparator("execute", args, executeCommand.options);
178
183
  const parsed: ExecuteCliArgs = { command: "" };
179
184
  const selectionFlags = emptySelectionFlags();
180
185
  const positional: string[] = [];
@@ -399,15 +404,20 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
399
404
  )?.request?.format ??
400
405
  parsed.format ??
401
406
  "text";
407
+ const text = resolvedFormat === "text";
408
+ if (text && parsed.verbose) {
409
+ printTextHeader(executeVerboseLines(envelope));
410
+ }
402
411
  const rendered = renderExecuteEnvelope(envelope, resolvedFormat, {
403
412
  verbose: parsed.verbose,
404
413
  });
405
414
  if (envelope.ok) {
406
415
  console.log(rendered);
407
- return 0;
416
+ } else {
417
+ console.error(rendered);
408
418
  }
409
- console.error(rendered);
410
- return 1;
419
+ if (text) printTextNotes(envelope);
420
+ return envelope.ok ? 0 : 1;
411
421
  } catch (error) {
412
422
  // Parse/usage errors land here too: surface them through the same typed
413
423
  // envelope as every other runner (no raw message, no exit code 2).
@@ -424,14 +434,12 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
424
434
  tips,
425
435
  );
426
436
  console.error(
427
- // codeql[js/clear-text-logging] No secret reaches this sink. The tainted 'password' flows in only as provenance (SettingSource={kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
428
437
  renderExecuteEnvelope(envelope, format, {
429
438
  verbose: parsed?.verbose ?? false,
430
439
  }),
431
440
  );
432
441
  } else {
433
442
  console.error(
434
- // codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
435
443
  formatCentrsErrorText(
436
444
  asCentrsError(error, {
437
445
  code: "input/invalid-command",
@@ -477,7 +485,6 @@ async function runExecuteFanoutCli(
477
485
  } catch (error) {
478
486
  const envelope = buildExecuteFanoutErrorEnvelope(parsed, error);
479
487
  console.error(
480
- // codeql[js/clear-text-logging] Twin of #80/#81/#83/#84. No secret logged: buildExecuteFanoutErrorEnvelope meta is {target:{},via,settings:{}}; CodeQL taints the whole request object but the builder projects the password away.
481
488
  renderExecuteFanoutEnvelope(envelope, format, {
482
489
  verbose: parsed.verbose ?? false,
483
490
  }),
@@ -581,6 +588,12 @@ async function runExecuteCmrCli(
581
588
  }
582
589
  }
583
590
 
591
+ /**
592
+ * The output format for an error that may have struck before parsing finished.
593
+ * Reads the argv the way the parser does: only tokens before `--` (after it is
594
+ * RouterOS input), last format flag wins, and a value-taking flag's value is
595
+ * skipped, so `--format yaml -- '/x' --json` renders YAML.
596
+ */
584
597
  function inferExecuteFormat(
585
598
  args: readonly string[],
586
599
  parsed?: ExecuteCliArgs,
@@ -588,22 +601,33 @@ function inferExecuteFormat(
588
601
  if (parsed?.format) {
589
602
  return parsed.format;
590
603
  }
591
- if (args.includes("--json")) {
592
- return "json";
593
- }
594
- const index = args.indexOf("--format");
595
- if (index >= 0) {
596
- const value = args[index + 1];
597
- if (
598
- value !== undefined &&
599
- executeOutputFormats.includes(value as ExecuteOutputFormat)
600
- ) {
601
- return value as ExecuteOutputFormat;
604
+ let format: ExecuteOutputFormat = "text";
605
+ for (let index = 0; index < args.length; index += 1) {
606
+ const arg = args[index];
607
+ if (arg === undefined || arg === "--") break;
608
+ if (arg === "--json") {
609
+ format = "json";
610
+ } else if (arg === "--format") {
611
+ const value = args[index + 1];
612
+ if (executeOutputFormats.includes(value as ExecuteOutputFormat)) {
613
+ format = value as ExecuteOutputFormat;
614
+ }
615
+ index += 1;
616
+ } else if (EXECUTE_VALUE_FLAGS.has(arg)) {
617
+ index += 1;
602
618
  }
603
619
  }
604
- return "text";
620
+ return format;
605
621
  }
606
622
 
623
+ const EXECUTE_VALUE_FLAGS: ReadonlySet<string> = new Set(
624
+ executeCommand.options.flatMap((option) =>
625
+ option.valueName === undefined
626
+ ? []
627
+ : option.flag.split("/").map((token) => token.trim()),
628
+ ),
629
+ );
630
+
607
631
  function parseIntegerFlag(value: string, flag: string): number {
608
632
  const parsed = Number.parseInt(value, 10);
609
633
  if (!Number.isInteger(parsed)) {
@@ -51,6 +51,7 @@ import type { ResolvedSetting } from "../resolver/settings.ts";
51
51
  import {
52
52
  type CliCommandMetadata,
53
53
  expectValue,
54
+ rejectFlagsAfterSeparator,
54
55
  renderCommandHelp,
55
56
  unknownFlagError,
56
57
  } from "./common.ts";
@@ -118,6 +119,7 @@ export interface ExplainCliArgs {
118
119
  }
119
120
 
120
121
  export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
122
+ rejectFlagsAfterSeparator("explain", args, explainCliCommand.options);
121
123
  const parsed: ExplainCliArgs = {
122
124
  positionals: [],
123
125
  failOn: "error",
package/src/cli/mcp.ts CHANGED
@@ -101,7 +101,6 @@ export async function runMcpCli(args: readonly string[]): Promise<number> {
101
101
  return 0;
102
102
  } catch (error) {
103
103
  console.error(
104
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
105
104
  formatCentrsErrorText(
106
105
  asCentrsError(error, {
107
106
  code: "internal/mcp-start",
@@ -15,17 +15,20 @@ import {
15
15
  buildRetrieveFanoutErrorEnvelope,
16
16
  describeCentrs,
17
17
  fanoutExitCode,
18
+ isStreamDataLine,
18
19
  type ResolvedRetrieveRequest,
19
20
  type RetrieveOutputFormat,
20
21
  type RetrieveRequest,
21
22
  renderRetrieveEnvelope,
22
23
  renderRetrieveFanoutEnvelope,
23
24
  renderRetrieveStreamLine,
25
+ resolveRetrieveOutputFormat,
24
26
  retrieve,
25
27
  retrieveFanout,
26
28
  retrieveFollow,
27
29
  retrieveOutputFormats,
28
30
  retrieveSample,
31
+ retrieveVerboseLines,
29
32
  retrieveWait,
30
33
  type ValidationTrace,
31
34
  validateRetrieveRequestShape,
@@ -34,6 +37,8 @@ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
34
37
  import {
35
38
  type CliCommandMetadata,
36
39
  expectValue,
40
+ printTextHeader,
41
+ printTextNotes,
37
42
  renderCommandHelp,
38
43
  unknownFlagError,
39
44
  } from "./common.ts";
@@ -125,13 +130,13 @@ export const retrieveCommand: CliCommandMetadata = {
125
130
  {
126
131
  flag: "--follow",
127
132
  description:
128
- "Keep the menu's rows current over native-api `listen`: snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only.",
133
+ "Keep the menu's rows current over native-api `listen`: a `started` line, snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only.",
129
134
  },
130
135
  {
131
136
  flag: "--sample",
132
137
  valueName: "<interval>",
133
138
  description:
134
- "Read the menu again every interval (with a unit, e.g. `5s`): one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api.",
139
+ "Read the menu again every interval (with a unit, e.g. `5s`): a `started` line, one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api.",
135
140
  },
136
141
  {
137
142
  flag: "--wait",
@@ -206,7 +211,7 @@ export const retrieveCommand: CliCommandMetadata = {
206
211
  },
207
212
  {
208
213
  flag: "--verbose",
209
- description: "Show resolved setting sources in text output.",
214
+ description: "Show resolved setting sources in text output, on stderr.",
210
215
  },
211
216
  ],
212
217
  };
@@ -283,6 +288,10 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
283
288
  request?: { format?: RetrieveOutputFormat };
284
289
  }
285
290
  )?.request?.format ?? "text";
291
+ const text = resolvedFormat === "text";
292
+ if (text && request.verbose) {
293
+ printTextHeader(retrieveVerboseLines(envelope));
294
+ }
286
295
  console.log(
287
296
  renderRetrieveEnvelope(
288
297
  envelope as Parameters<typeof renderRetrieveEnvelope>[0],
@@ -290,6 +299,7 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
290
299
  { verbose: request.verbose },
291
300
  ),
292
301
  );
302
+ if (text) printTextNotes(envelope);
293
303
  return 0;
294
304
  } catch (error) {
295
305
  const format = inferRequestedFormat(args, request);
@@ -313,14 +323,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
313
323
  tips,
314
324
  );
315
325
  console.error(
316
- // codeql[js/clear-text-logging] Twin of the established redacted-request dismissals: error builders carry only targetInput (host)+via (protocol); the raw password never reaches this render.
317
326
  renderRetrieveEnvelope(envelope, format, {
318
327
  verbose: request?.verbose ?? false,
319
328
  }),
320
329
  );
321
330
  } else {
322
331
  console.error(
323
- // codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
324
332
  formatCentrsErrorText(
325
333
  asCentrsError(error, {
326
334
  code: "input/invalid-command",
@@ -340,14 +348,21 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
340
348
 
341
349
  /**
342
350
  * Every `--follow`/`--sample` envelope goes to stdout, one per line, errors
343
- * included; any failure sets exit 1, even after successful lines. Ctrl-C asks
344
- * for a bounded stop that still ends with a summary.
351
+ * included; any failure sets exit 1, even after successful lines. In text,
352
+ * stdout is data (#470, #473): only `frame`/`sample` rows go there, and the
353
+ * status lines, tips, warnings and errors go to stderr. Ctrl-C asks for a
354
+ * bounded stop that still ends with a summary.
345
355
  */
346
356
  async function runRetrieveStreamCli(
347
357
  request: RetrieveCliArgs,
348
358
  args: readonly string[],
349
359
  ): Promise<number> {
350
- const format = inferRequestedFormat(args, request);
360
+ // Resolved once, up front: a config-file `CENTRS_FORMAT` is not in `args`,
361
+ // and follow frame lines carry no request (#462). An invalid format is
362
+ // reported by the stream's own error line, rendered as the args ask.
363
+ const format = await resolveRetrieveOutputFormat(request, Bun.env).catch(() =>
364
+ inferRequestedFormat(args, request),
365
+ );
351
366
  const controller = new AbortController();
352
367
  const onSigint = (): void => controller.abort();
353
368
  process.on("SIGINT", onSigint);
@@ -357,9 +372,14 @@ async function runRetrieveStreamCli(
357
372
  for await (const envelope of stream(request, Bun.env, {
358
373
  signal: controller.signal,
359
374
  })) {
360
- // A config-file `CENTRS_FORMAT` is known only after resolution.
361
- const resolvedFormat = envelope.meta.operation?.request.format ?? format;
362
- console.log(renderRetrieveStreamLine(envelope, resolvedFormat));
375
+ const line = renderRetrieveStreamLine(envelope, format);
376
+ if (format !== "text" || isStreamDataLine(envelope)) {
377
+ console.log(line);
378
+ } else {
379
+ // `started` renders its own tips; the summary repeats them.
380
+ if (line) console.error(line);
381
+ printTextNotes({ warnings: envelope.warnings, tips: [] });
382
+ }
363
383
  if (!envelope.ok) exitCode = 1;
364
384
  }
365
385
  } finally {
@@ -379,14 +399,17 @@ async function runRetrieveWaitCli(
379
399
  const envelope = await retrieveWait(request, Bun.env, {
380
400
  signal: controller.signal,
381
401
  });
402
+ const format =
403
+ envelope.meta.operation?.request.format ??
404
+ inferRequestedFormat(args, request);
405
+ const text = format === "text";
406
+ if (text && request.verbose) {
407
+ printTextHeader(retrieveVerboseLines(envelope));
408
+ }
382
409
  console.log(
383
- renderRetrieveEnvelope(
384
- envelope,
385
- envelope.meta.operation?.request.format ??
386
- inferRequestedFormat(args, request),
387
- { verbose: request.verbose },
388
- ),
410
+ renderRetrieveEnvelope(envelope, format, { verbose: request.verbose }),
389
411
  );
412
+ if (text) printTextNotes(envelope);
390
413
  return envelope.ok ? 0 : 1;
391
414
  } finally {
392
415
  process.off("SIGINT", onSigint);
@@ -423,7 +446,6 @@ async function runRetrieveFanoutCli(
423
446
  } catch (error) {
424
447
  const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
425
448
  console.error(
426
- // codeql[js/clear-text-logging] Twin of #80/#81/#83. No secret logged: error builders carry only targetInput (host)+via (protocol); serializeCentrsError holds code/summary/remediation/context.
427
449
  renderRetrieveFanoutEnvelope(envelope, format, {
428
450
  verbose: request.verbose ?? false,
429
451
  }),
@@ -244,7 +244,6 @@ export async function runSettingsCli(args: readonly string[]): Promise<number> {
244
244
  }
245
245
 
246
246
  const format = parsed.format ?? "text";
247
- // codeql[js/clear-text-logging] passwordSet is a has-password boolean, never the secret; the property-NAME heuristic (maybePassword) matches the field name, not its value type.
248
247
  console.log(renderSettingsEnvelope(envelope, format));
249
248
  return 0;
250
249
  } catch (error) {
@@ -263,10 +262,8 @@ export async function runSettingsCli(args: readonly string[]): Promise<number> {
263
262
  centrsError,
264
263
  );
265
264
  if (format === "json" || format === "yaml") {
266
- // codeql[js/clear-text-logging] sink renders only a literal error string (e.g. "Missing value for --password."), never the real value; CodeQL over-taints via shared expectValue/CentrsError code.
267
265
  console.error(renderSettingsEnvelope(envelope, format));
268
266
  } else {
269
- // codeql[js/clear-text-logging] sink renders only a literal error string (e.g. "Missing value for --password."), never the real value; CodeQL over-taints via shared expectValue/CentrsError code.
270
267
  console.error(formatCentrsErrorText(centrsError));
271
268
  }
272
269
  return 1;
@@ -188,12 +188,10 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
188
188
  if (format === "json" || format === "yaml") {
189
189
  const envelope = withTips(buildTerminalErrorEnvelope(error), tips);
190
190
  console.error(
191
- // codeql[js/clear-text-logging] CLI error renderer false positive; password reaches neither the error nor the stderr renderers. Dismissed across all CLI commands.
192
191
  format === "yaml" ? toYaml(envelope) : JSON.stringify(envelope),
193
192
  );
194
193
  } else {
195
194
  console.error(
196
- // codeql[js/clear-text-logging] Password parsed from args never reaches error.message in the text-format catch path; the parsed request (which holds the raw password) is never rendered.
197
195
  formatCentrsErrorText(
198
196
  asCentrsError(error, {
199
197
  code: "input/invalid-command",
@@ -21,12 +21,15 @@ import {
21
21
  transferEnvelope,
22
22
  transferFanout,
23
23
  transferOutputFormats,
24
+ transferVerboseLines,
24
25
  transferVerbs,
25
26
  } from "../index.ts";
26
27
  import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
27
28
  import {
28
29
  type CliCommandMetadata,
29
30
  expectValue,
31
+ printTextHeader,
32
+ printTextNotes,
30
33
  renderCommandHelp,
31
34
  unknownFlagError,
32
35
  } from "./common.ts";
@@ -167,7 +170,7 @@ export const transferCommand: CliCommandMetadata = {
167
170
  },
168
171
  {
169
172
  flag: "--verbose",
170
- description: "Show the resolved transport in text output.",
173
+ description: "Show the resolved transport in text output, on stderr.",
171
174
  },
172
175
  ],
173
176
  };
@@ -206,11 +209,12 @@ export async function runTransferCli(
206
209
  const rendered = renderTransferEnvelope(envelope, format, {
207
210
  verbose: request.verbose,
208
211
  });
212
+ if (format === "text" && request.verbose) {
213
+ printTextHeader(transferVerboseLines(envelope));
214
+ }
209
215
  if (!envelope.ok) {
210
- console.error(
211
- // codeql[js/clear-text-logging] The envelope carries auth provenance only (`username`, `passwordProvided`, setting sources), never the password; see buildTransferErrorEnvelopeFromResolved.
212
- format === "text" ? rendered + formatTipsText(envelope.tips) : rendered,
213
- );
216
+ console.error(rendered);
217
+ if (format === "text") printTextNotes(envelope);
214
218
  return 1;
215
219
  }
216
220
  // A download to stdout (`local === "-"`) has already streamed the file bytes
@@ -221,6 +225,7 @@ export async function runTransferCli(
221
225
  } else {
222
226
  console.log(rendered);
223
227
  }
228
+ if (format === "text") printTextNotes(envelope);
224
229
  return 0;
225
230
  } catch (error) {
226
231
  // Drop credential fields before any renderer touches the request: error
@@ -243,14 +248,12 @@ export async function runTransferCli(
243
248
  tips,
244
249
  );
245
250
  console.error(
246
- // codeql[js/clear-text-logging] The transfer error path redacts password/cdbPassword before any renderer (redactTransferRequest), and buildTransferErrorEnvelope only emits targetInput.
247
251
  renderTransferEnvelope(envelope, format, {
248
252
  verbose: safeRequest?.verbose ?? false,
249
253
  }),
250
254
  );
251
255
  } else {
252
256
  console.error(
253
- // codeql[js/clear-text-logging] Credentials are redacted from the request before rendering (redactTransferRequest) and never reach the error/stderr; same known false positive dismissed across all CLI commands.
254
257
  formatCentrsErrorText(
255
258
  asCentrsError(error, {
256
259
  code: "input/invalid-command",
@@ -297,7 +300,6 @@ async function runTransferFanoutCli(
297
300
  error,
298
301
  );
299
302
  console.error(
300
- // codeql[js/clear-text-logging] No secret logged: request is redactTransferRequest'd (password/cdbPassword stripped) before the builder; fan-out error meta is {target:{},via,settings:{}}.
301
303
  renderTransferFanoutEnvelope(envelope, format, {
302
304
  verbose: request.verbose ?? false,
303
305
  }),