@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
package/src/cli/api.ts ADDED
@@ -0,0 +1,660 @@
1
+ import {
2
+ type ApiOutputFormat,
3
+ type ApiRequest,
4
+ apiEnvelope,
5
+ apiListen,
6
+ apiOutputFormats,
7
+ buildApiErrorEnvelope,
8
+ normalizeApiEndpoint,
9
+ renderApiEnvelope,
10
+ renderApiStreamLine,
11
+ } from "../api.ts";
12
+ import {
13
+ apiFanout,
14
+ buildApiFanoutErrorEnvelope,
15
+ renderApiFanoutEnvelope,
16
+ } from "../api-fanout.ts";
17
+ import { fanoutExitCode } from "../core/fanout.ts";
18
+ import {
19
+ asCentrsError,
20
+ CentrsError,
21
+ formatCentrsErrorText,
22
+ } from "../errors.ts";
23
+ import { describeCentrs } from "../index.ts";
24
+ import {
25
+ type CliCommandMetadata,
26
+ expectValue,
27
+ renderCommandHelp,
28
+ unknownFlagError,
29
+ } from "./common.ts";
30
+ import {
31
+ buildTargetSelectionTips,
32
+ cdbFileFromArgs,
33
+ formatTipsText,
34
+ isMissingTargetError,
35
+ missingTargetError,
36
+ withTips,
37
+ } from "./missing-target.ts";
38
+ import {
39
+ buildTargetSelection,
40
+ consumeSelectionFlag,
41
+ emptySelectionFlags,
42
+ isFanoutMode,
43
+ type SelectionFlags,
44
+ selectionCommandOptions,
45
+ } from "./selection.ts";
46
+
47
+ export const apiCommand: CliCommandMetadata = {
48
+ name: "api",
49
+ usage: "centrs api <router> <endpoint> [flags]",
50
+ summary:
51
+ "Structured RouterOS API passthrough (gh-api style) over REST or native API.",
52
+ options: [
53
+ {
54
+ flag: "-X / --method",
55
+ valueName: "<verb>",
56
+ description:
57
+ "HTTP method, default GET (case-insensitive). GET→print, PUT→add, PATCH→set, DELETE→remove, POST→run.",
58
+ },
59
+ {
60
+ flag: "-f",
61
+ valueName: "<key=value>",
62
+ description:
63
+ "Body field, repeatable; assembled into the JSON body (verbatim string values).",
64
+ },
65
+ {
66
+ flag: "-d / --data",
67
+ valueName: "<json>",
68
+ description: "Raw JSON request body. Conflicts with `-f` / `--input`.",
69
+ },
70
+ {
71
+ flag: "--input",
72
+ valueName: "<file|->",
73
+ description: "Read the raw JSON body from a file or stdin (`-`).",
74
+ },
75
+ {
76
+ flag: "--query / --filter",
77
+ valueName: "<expr>",
78
+ description:
79
+ "RouterOS-side row filter, AND-combined, repeatable: name=value, name!=value, name>value, name<value, name.",
80
+ },
81
+ {
82
+ flag: "--raw-query",
83
+ valueName: "<word>",
84
+ description:
85
+ "Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions.",
86
+ },
87
+ {
88
+ flag: "--attribute / --proplist",
89
+ valueName: "<a,b>",
90
+ description: "Property projection → `.proplist`.",
91
+ },
92
+ {
93
+ flag: "--raw",
94
+ description:
95
+ "Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does not imply `--yes`.",
96
+ },
97
+ {
98
+ flag: "--yes",
99
+ description:
100
+ "Confirm a mutating (non-read) request in non-interactive runs.",
101
+ },
102
+ {
103
+ flag: "--stream / --listen",
104
+ description:
105
+ "Follow changes as an NDJSON envelope stream (native-api only; the `/listen` endpoint infers it). Ends with a summary envelope.",
106
+ },
107
+ {
108
+ flag: "--count",
109
+ valueName: "<n>",
110
+ description: "Stop a `--stream` after N change frames.",
111
+ },
112
+ {
113
+ flag: "--duration",
114
+ valueName: "<dur>",
115
+ description:
116
+ "Stop a `--stream` after this wall-clock window (e.g. `5s`).",
117
+ },
118
+ {
119
+ flag: "--via",
120
+ valueName: "<rest-api|native-api>",
121
+ description: "Pin the transport; no silent downgrade. Default rest-api.",
122
+ },
123
+ ...selectionCommandOptions,
124
+ {
125
+ flag: "--host",
126
+ valueName: "<host|url>",
127
+ description: "Override the resolved host or base URL for the target.",
128
+ },
129
+ {
130
+ flag: "--port",
131
+ valueName: "<port>",
132
+ description: "Override the resolved management port.",
133
+ },
134
+ {
135
+ flag: "--username / --user / -u",
136
+ valueName: "<name>",
137
+ description: "RouterOS username. Falls back to `CENTRS_USERNAME`.",
138
+ },
139
+ {
140
+ flag: "--password",
141
+ valueName: "<secret>",
142
+ description: "RouterOS password. Falls back to `CENTRS_PASSWORD`.",
143
+ },
144
+ {
145
+ flag: "--insecure",
146
+ description:
147
+ "Accept a self-signed `api-ssl`/REST TLS cert. Default verifies.",
148
+ },
149
+ {
150
+ flag: "--cdb-file",
151
+ valueName: "<path>",
152
+ description: "Read target credentials from a WinBox CDB file.",
153
+ },
154
+ {
155
+ flag: "--cdb-password",
156
+ valueName: "<password>",
157
+ description: "Decrypt an encrypted WinBox CDB file.",
158
+ },
159
+ {
160
+ flag: "--resolve",
161
+ valueName: "<none|arp>",
162
+ description:
163
+ "Resolve a MAC-address target to an IP via the host ARP cache (default none).",
164
+ },
165
+ {
166
+ flag: "--timeout",
167
+ valueName: "<duration>",
168
+ description: "Per-request timeout (for REST, max 60s).",
169
+ },
170
+ {
171
+ flag: "--validate[=false]",
172
+ description:
173
+ "Run `/console/inspect` validation before the request (default true).",
174
+ },
175
+ {
176
+ flag: "--format",
177
+ valueName: "<json|yaml|text>",
178
+ description:
179
+ "Output format. Defaults to json for api; `CENTRS_FORMAT` overrides.",
180
+ },
181
+ {
182
+ flag: "--json",
183
+ description: "Shortcut for `--format json`.",
184
+ },
185
+ {
186
+ flag: "--verbose",
187
+ description: "Include additional context in text output.",
188
+ },
189
+ ],
190
+ };
191
+
192
+ interface ApiCliArgs extends ApiRequest {
193
+ help?: boolean;
194
+ format?: ApiOutputFormat;
195
+ /** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
196
+ inputPath?: string;
197
+ /** Parsed target-selection flags (fan-out grammar). */
198
+ selectionFlags?: SelectionFlags;
199
+ /** Positionals before the endpoint — the fan-out target list. */
200
+ targetPositionals?: string[];
201
+ }
202
+
203
+ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
204
+ const parsed: ApiCliArgs = { endpoint: "" };
205
+ const positional: string[] = [];
206
+ const selectionFlags = emptySelectionFlags();
207
+
208
+ for (let index = 0; index < args.length; index += 1) {
209
+ const arg = args[index];
210
+ if (arg === undefined) {
211
+ continue;
212
+ }
213
+ if (arg.startsWith("--validate=")) {
214
+ parsed.validate = parseBooleanFlag(arg.slice("--validate=".length), arg);
215
+ continue;
216
+ }
217
+ switch (arg) {
218
+ case "--help":
219
+ case "-h":
220
+ parsed.help = true;
221
+ break;
222
+ case "--json":
223
+ parsed.format = "json";
224
+ break;
225
+ case "--verbose":
226
+ parsed.verbose = true;
227
+ break;
228
+ case "--yes":
229
+ parsed.yes = true;
230
+ break;
231
+ case "--raw":
232
+ parsed.raw = true;
233
+ break;
234
+ case "--stream":
235
+ case "--listen":
236
+ parsed.listen = true;
237
+ break;
238
+ case "--validate":
239
+ parsed.validate = true;
240
+ break;
241
+ case "--no-validate":
242
+ parsed.validate = false;
243
+ break;
244
+ case "-X":
245
+ case "--method":
246
+ parsed.method = expectValue(args, ++index, arg);
247
+ break;
248
+ case "-f":
249
+ case "--field": {
250
+ const kv = expectValue(args, ++index, arg);
251
+ const eq = kv.indexOf("=");
252
+ if (eq <= 0) {
253
+ throw new Error(`${arg} expects key=value; got ${kv}`);
254
+ }
255
+ parsed.fields = {
256
+ ...(parsed.fields ?? {}),
257
+ [kv.slice(0, eq)]: kv.slice(eq + 1),
258
+ };
259
+ break;
260
+ }
261
+ case "-d":
262
+ case "--data":
263
+ parsed.data = expectValue(args, ++index, arg);
264
+ break;
265
+ case "--input":
266
+ parsed.inputPath = expectValue(args, ++index, arg);
267
+ break;
268
+ case "--query":
269
+ case "--filter":
270
+ parsed.query = [
271
+ ...(parsed.query ?? []),
272
+ expectValue(args, ++index, arg),
273
+ ];
274
+ break;
275
+ case "--raw-query":
276
+ parsed.rawQuery = [
277
+ ...(parsed.rawQuery ?? []),
278
+ expectValue(args, ++index, arg),
279
+ ];
280
+ break;
281
+ case "--attribute":
282
+ case "--proplist":
283
+ parsed.proplist = [
284
+ ...(parsed.proplist ?? []),
285
+ expectValue(args, ++index, arg),
286
+ ];
287
+ break;
288
+ case "--duration":
289
+ parsed.duration = expectValue(args, ++index, arg);
290
+ break;
291
+ case "--count":
292
+ parsed.count = parseIntegerFlag(expectValue(args, ++index, arg), arg);
293
+ break;
294
+ case "--via":
295
+ parsed.via = expectValue(args, ++index, arg);
296
+ break;
297
+ case "--host":
298
+ parsed.host = expectValue(args, ++index, arg);
299
+ break;
300
+ case "--port":
301
+ parsed.port = parseIntegerFlag(expectValue(args, ++index, arg), arg);
302
+ break;
303
+ case "--user":
304
+ case "-u":
305
+ case "--username":
306
+ parsed.username = expectValue(args, ++index, arg);
307
+ break;
308
+ case "--password":
309
+ parsed.password = expectValue(args, ++index, arg);
310
+ break;
311
+ case "--insecure":
312
+ parsed.insecure = true;
313
+ break;
314
+ case "--cdb-file":
315
+ parsed.cdbFile = expectValue(args, ++index, arg);
316
+ break;
317
+ case "--cdb-password":
318
+ parsed.cdbPassword = expectValue(args, ++index, arg);
319
+ break;
320
+ case "--resolve":
321
+ parsed.resolve = expectValue(args, ++index, arg);
322
+ break;
323
+ case "--timeout":
324
+ parsed.timeout = expectValue(args, ++index, arg);
325
+ break;
326
+ case "--format": {
327
+ const value = expectValue(args, ++index, arg);
328
+ if (!apiOutputFormats.includes(value as ApiOutputFormat)) {
329
+ throw new Error(
330
+ `--format must be one of ${apiOutputFormats.join(", ")}; got ${value}.`,
331
+ );
332
+ }
333
+ parsed.format = value as ApiOutputFormat;
334
+ break;
335
+ }
336
+ default: {
337
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
338
+ if (consumed !== null) {
339
+ index = consumed;
340
+ break;
341
+ }
342
+ if (arg.startsWith("-")) {
343
+ throw unknownFlagError("api", arg, apiCommand.options);
344
+ }
345
+ positional.push(arg);
346
+ break;
347
+ }
348
+ }
349
+ }
350
+
351
+ // api/retrieve positional boundary: the FINAL positional is the endpoint; every
352
+ // preceding positional is a fan-out target.
353
+ parsed.endpoint = positional.at(-1) ?? "";
354
+ parsed.targetPositionals = positional.slice(0, -1);
355
+ parsed.selectionFlags = selectionFlags;
356
+ parsed.targetInput = parsed.targetPositionals[0];
357
+ parsed.stdinIsTty = process.stdin.isTTY;
358
+ return parsed;
359
+ }
360
+
361
+ export async function runApiCli(args: readonly string[]): Promise<number> {
362
+ let parsed: ApiCliArgs | undefined;
363
+ try {
364
+ parsed = parseApiCliArgs(args);
365
+ if (parsed.help) {
366
+ console.log(renderCommandHelp(describeCentrs(), apiCommand));
367
+ return 0;
368
+ }
369
+ // Resolve `--input` (file / stdin) before handing the body to the orchestrator.
370
+ if (parsed.inputPath !== undefined) {
371
+ const inputPath = parsed.inputPath;
372
+ try {
373
+ parsed.inputBody =
374
+ inputPath === "-"
375
+ ? await Bun.stdin.text()
376
+ : await Bun.file(inputPath).text();
377
+ } catch (error) {
378
+ throw new CentrsError({
379
+ code: "input/local-file-not-found",
380
+ summary: `Cannot read the --input body from ${inputPath === "-" ? "stdin" : inputPath}.`,
381
+ remediation:
382
+ "Check the path and read permissions, or pipe the JSON body into `--input -`.",
383
+ context: { input: inputPath },
384
+ cause: error,
385
+ });
386
+ }
387
+ }
388
+
389
+ // Fan-out mode (selector flag present, or >1 positional target) has its own
390
+ // envelope shape, guards, and granular exit code. It runs first and rejects
391
+ // `--stream`/`--listen` + fan-out itself (single-session is exclusive).
392
+ const selectionFlags = parsed.selectionFlags ?? emptySelectionFlags();
393
+ const targetPositionals = parsed.targetPositionals ?? [];
394
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
395
+ return await runApiFanoutCli(
396
+ parsed,
397
+ selectionFlags,
398
+ targetPositionals,
399
+ args,
400
+ );
401
+ }
402
+
403
+ // Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
404
+ // the NDJSON envelope stream instead of a single one-shot envelope.
405
+ const streaming =
406
+ parsed.listen === true ||
407
+ (parsed.endpoint.length > 0 &&
408
+ normalizeApiEndpoint(parsed.endpoint).listen);
409
+ if (streaming) {
410
+ return await runApiListenCli(parsed, args);
411
+ }
412
+
413
+ if (!parsed.targetInput) {
414
+ throw missingTargetError({
415
+ command: "api",
416
+ summary: "`centrs api` requires a <router> and an <endpoint>.",
417
+ remediation:
418
+ "Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
419
+ });
420
+ }
421
+
422
+ const envelope = await apiEnvelope(parsed);
423
+ const resolvedFormat =
424
+ (
425
+ envelope.meta.operation as {
426
+ request?: { format?: ApiOutputFormat };
427
+ }
428
+ )?.request?.format ??
429
+ parsed.format ??
430
+ "json";
431
+ const rendered = renderApiEnvelope(envelope, resolvedFormat, {
432
+ raw: parsed.raw,
433
+ verbose: parsed.verbose,
434
+ });
435
+ if (envelope.ok) {
436
+ console.log(rendered);
437
+ return 0;
438
+ }
439
+ console.error(rendered);
440
+ return 1;
441
+ } catch (error) {
442
+ const format = inferApiFormat(args, parsed);
443
+ const tips = isMissingTargetError(error)
444
+ ? await buildTargetSelectionTips({
445
+ cdbFile: parsed?.cdbFile ?? cdbFileFromArgs(args),
446
+ env: Bun.env,
447
+ })
448
+ : [];
449
+ if (parsed?.raw) {
450
+ // `--raw` defines a compact JSON error contract (code/message → stderr);
451
+ // keep that machine shape on the pre-envelope failure path too (e.g. a
452
+ // missing `--input` file) instead of falling back to plain text.
453
+ const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
454
+ console.error(
455
+ // 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).
456
+ renderApiEnvelope(envelope, format, {
457
+ raw: true,
458
+ verbose: parsed.verbose ?? false,
459
+ }),
460
+ );
461
+ } else if (format === "json" || format === "yaml") {
462
+ const envelope = withTips(
463
+ buildApiErrorEnvelope(parsed ?? { endpoint: "" }, error),
464
+ tips,
465
+ );
466
+ console.error(
467
+ // 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).
468
+ renderApiEnvelope(envelope, format, {
469
+ verbose: parsed?.verbose ?? false,
470
+ }),
471
+ );
472
+ } else {
473
+ console.error(
474
+ // 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).
475
+ formatCentrsErrorText(
476
+ asCentrsError(error, {
477
+ code: "input/invalid-command",
478
+ summary: error instanceof Error ? error.message : String(error),
479
+ remediation:
480
+ "Use `centrs api --help` to inspect the supported endpoint shape and flags.",
481
+ }),
482
+ { verbose: parsed?.verbose ?? args.includes("--verbose") },
483
+ ) + formatTipsText(tips),
484
+ );
485
+ }
486
+ return 1;
487
+ }
488
+ }
489
+
490
+ async function runApiFanoutCli(
491
+ parsed: ApiCliArgs,
492
+ selectionFlags: SelectionFlags,
493
+ targetPositionals: readonly string[],
494
+ args: readonly string[],
495
+ ): Promise<number> {
496
+ const selection = buildTargetSelection(selectionFlags, targetPositionals);
497
+ const format = inferApiFormat(args, parsed);
498
+ try {
499
+ // `--raw` strips the envelope; incompatible with per-target envelopes.
500
+ if (parsed.raw) {
501
+ throw new CentrsError({
502
+ code: "usage/conflicting-flags",
503
+ summary:
504
+ "`--raw` cannot combine with a multi-target selection; fan-out needs per-target envelopes.",
505
+ remediation:
506
+ "Drop `--raw` to fan out, or target a single router for the bare RouterOS body.",
507
+ context: { flags: ["--raw", "fanout"] },
508
+ });
509
+ }
510
+ // `--listen`/`--stream` is single-session.
511
+ const listenRequested =
512
+ (parsed.listen ?? false) || endpointInfersListen(parsed.endpoint);
513
+ if (listenRequested) {
514
+ throw new CentrsError({
515
+ code: "usage/fanout-not-supported",
516
+ summary:
517
+ "`api --stream`/`--listen` is single-session and cannot fan out across multiple targets.",
518
+ remediation:
519
+ "Follow a stream against a single router (no `--group`/`--where`/`--all`/`--default`/multiple positionals).",
520
+ context: { capability: "listen" },
521
+ });
522
+ }
523
+ const envelope = await apiFanout(
524
+ parsed,
525
+ selection,
526
+ Bun.env,
527
+ {},
528
+ {
529
+ concurrency: selectionFlags.concurrency,
530
+ allowAdhoc: true,
531
+ },
532
+ );
533
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
534
+ console.log(
535
+ renderApiFanoutEnvelope(envelope, resolvedFormat, {
536
+ verbose: parsed.verbose ?? false,
537
+ }),
538
+ );
539
+ return fanoutExitCode(envelope);
540
+ } catch (error) {
541
+ const envelope = buildApiFanoutErrorEnvelope(
542
+ parsed,
543
+ selection,
544
+ error,
545
+ Bun.env,
546
+ );
547
+ console.error(
548
+ // 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.
549
+ renderApiFanoutEnvelope(envelope, format, {
550
+ verbose: parsed.verbose ?? false,
551
+ }),
552
+ );
553
+ return 1;
554
+ }
555
+ }
556
+
557
+ /** Whether an endpoint's trailing segment infers `--listen` (without throwing). */
558
+ function endpointInfersListen(endpoint: string): boolean {
559
+ try {
560
+ return normalizeApiEndpoint(endpoint).listen;
561
+ } catch {
562
+ return false;
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Consume the open-ended `--stream` follow: print one line per change frame
568
+ * (NDJSON under `json`, human rows under `text`), then the terminating summary.
569
+ * Ctrl-C (SIGINT) cancels the listen and still emits the summary. The exit code
570
+ * reflects whether the stream *started* cleanly — a failure before the first
571
+ * frame is `1`; a mid-stream error frame leaves a started stream at `0`.
572
+ */
573
+ async function runApiListenCli(
574
+ parsed: ApiCliArgs,
575
+ args: readonly string[],
576
+ ): Promise<number> {
577
+ const format = inferApiFormat(args, parsed);
578
+ const controller = new AbortController();
579
+ const onSigint = (): void => controller.abort();
580
+ process.on("SIGINT", onSigint);
581
+ let exitCode = 0;
582
+ let first = true;
583
+ try {
584
+ for await (const envelope of apiListen(
585
+ parsed,
586
+ Bun.env,
587
+ controller.signal,
588
+ )) {
589
+ const line = renderApiStreamLine(envelope, format, {
590
+ raw: parsed.raw,
591
+ verbose: parsed.verbose,
592
+ });
593
+ if (envelope.ok) {
594
+ console.log(line);
595
+ } else {
596
+ console.error(line);
597
+ if (first) {
598
+ exitCode = 1;
599
+ }
600
+ }
601
+ first = false;
602
+ }
603
+ } finally {
604
+ process.off("SIGINT", onSigint);
605
+ }
606
+ return exitCode;
607
+ }
608
+
609
+ function inferApiFormat(
610
+ args: readonly string[],
611
+ parsed?: ApiCliArgs,
612
+ ): ApiOutputFormat {
613
+ if (parsed?.format) {
614
+ return parsed.format;
615
+ }
616
+ if (args.includes("--json")) {
617
+ return "json";
618
+ }
619
+ const index = args.indexOf("--format");
620
+ if (index >= 0) {
621
+ const value = args[index + 1];
622
+ if (
623
+ value !== undefined &&
624
+ apiOutputFormats.includes(value as ApiOutputFormat)
625
+ ) {
626
+ return value as ApiOutputFormat;
627
+ }
628
+ }
629
+ // Honor CENTRS_FORMAT on the parse-time error path so the catch render matches
630
+ // buildApiErrorEnvelope's env-aware `meta.operation.request.format`. CLI flags
631
+ // above still win.
632
+ const envFormat = Bun.env["CENTRS_FORMAT"];
633
+ if (
634
+ envFormat !== undefined &&
635
+ apiOutputFormats.includes(envFormat as ApiOutputFormat)
636
+ ) {
637
+ return envFormat as ApiOutputFormat;
638
+ }
639
+ // api is machine-first: default to the structured json envelope, not text.
640
+ return "json";
641
+ }
642
+
643
+ function parseIntegerFlag(value: string, flag: string): number {
644
+ // Validate the whole token: `Number.parseInt` would silently accept prefixes
645
+ // like `8728ms` / `10abc` and send a request to the wrong port/count.
646
+ if (!/^-?\d+$/.test(value.trim())) {
647
+ throw new Error(`${flag} must be an integer; got ${value}.`);
648
+ }
649
+ return Number.parseInt(value, 10);
650
+ }
651
+
652
+ function parseBooleanFlag(value: string, flag: string): boolean {
653
+ if (["true", "1", "yes", "on"].includes(value.toLowerCase())) {
654
+ return true;
655
+ }
656
+ if (["false", "0", "no", "off"].includes(value.toLowerCase())) {
657
+ return false;
658
+ }
659
+ throw new Error(`${flag} must be true or false; got ${value}.`);
660
+ }
package/src/cli/btest.ts CHANGED
@@ -29,8 +29,10 @@ import type { BtestDirection, BtestProtocol } from "../protocols/btest.ts";
29
29
  import { toYaml } from "../retrieve.ts";
30
30
  import {
31
31
  type CliCommandMetadata,
32
+ type CliCommandOption,
32
33
  expectValue,
33
34
  renderCommandHelp,
35
+ unknownFlagError,
34
36
  } from "./common.ts";
35
37
  import {
36
38
  buildTargetSelectionTips,
@@ -136,6 +138,22 @@ export const btestCommand: CliCommandMetadata = {
136
138
  ],
137
139
  };
138
140
 
141
+ /**
142
+ * btest's single option list documents both subcommands. Scope "did you mean?"
143
+ * hints to the active mode so a client typo never suggests a server-only flag
144
+ * (and vice versa). Shared flags carry no `(client)`/`(server)` marker, so they
145
+ * stay in both; if a marker is ever dropped the flag just widens to both
146
+ * modes — it degrades to the old behavior, never a crash.
147
+ */
148
+ function btestOptionsFor(
149
+ mode: "client" | "server",
150
+ ): readonly CliCommandOption[] {
151
+ const other = mode === "client" ? "(server)" : "(client)";
152
+ return btestCommand.options.filter(
153
+ (option) => !option.description.includes(other),
154
+ );
155
+ }
156
+
139
157
  /** Parse a bandwidth value: a bare integer or a `K`/`M`/`G` suffix (bits/sec). */
140
158
  function parseBandwidth(value: string, flag: string): number {
141
159
  const match = value.trim().match(/^(\d+(?:\.\d+)?)([kKmMgG]?)$/);
@@ -276,7 +294,7 @@ function parseClientArgs(args: readonly string[]): ClientCliArgs {
276
294
  break;
277
295
  default:
278
296
  if (arg.startsWith("-"))
279
- throw new Error(`Unknown btest client flag: ${arg}`);
297
+ throw unknownFlagError("btest", arg, btestOptionsFor("client"));
280
298
  if (request.targetInput !== undefined)
281
299
  throw new Error(`Unexpected extra argument: ${arg}`);
282
300
  request.targetInput = arg;
@@ -341,7 +359,7 @@ function parseServerArgs(args: readonly string[]): ServerCliArgs {
341
359
  break;
342
360
  default:
343
361
  if (arg.startsWith("-"))
344
- throw new Error(`Unknown btest server flag: ${arg}`);
362
+ throw unknownFlagError("btest", arg, btestOptionsFor("server"));
345
363
  throw new Error(
346
364
  `\`centrs btest server\` takes no positional arguments; got: ${arg}.`,
347
365
  );