@tikoci/centrs 0.1.2 → 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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
package/src/api.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import {
48
48
  type CdbResolution,
49
49
  isIpTransport,
50
+ loadEnvFileDefaults,
50
51
  parseDuration,
51
52
  parseResolvePolicy,
52
53
  type ResolvedAuth,
@@ -500,8 +501,20 @@ function disabledValidationMeta(): EnvelopeValidationMeta {
500
501
  export async function resolveApiRequest(
501
502
  request: ApiRequest,
502
503
  env: Record<string, string | undefined>,
504
+ override?: {
505
+ /**
506
+ * A pre-resolved CDB record, supplied by fan-out so the CDB is loaded ONCE
507
+ * (not per member) and a `--default`/literal member is never re-resolved
508
+ * through `resolveCdb`'s `__default__` synthetic-target fallback.
509
+ */
510
+ cdbResolution?: CdbResolution;
511
+ /** A pre-loaded `centrs.env` config tier, supplied by fan-out so the file
512
+ * is read ONCE (not per member). */
513
+ config?: Record<string, string | undefined>;
514
+ },
503
515
  ): Promise<ResolvedApiRequest> {
504
516
  validateApiRequestShape(request);
517
+ const config = override?.config ?? (await loadEnvFileDefaults(env));
505
518
  const normalized = normalizeApiEndpoint(request.endpoint);
506
519
  const method = parseApiMethod(request.method);
507
520
  const verb = mapMethodToVerb(method);
@@ -529,16 +542,19 @@ export async function resolveApiRequest(
529
542
  const query = buildApiQuery(request);
530
543
  const proplist = buildApiProplist(request);
531
544
 
532
- const cdbResolution = await resolveCdb(
533
- {
534
- targetInput: request.targetInput,
535
- cdbFile: request.cdbFile,
536
- cdbPassword: request.cdbPassword,
537
- },
538
- env,
539
- );
540
- const via = resolveApiProtocol(request, env, listen, cdbResolution);
541
- const format = resolveApiFormat(request, env);
545
+ const cdbResolution =
546
+ override?.cdbResolution ??
547
+ (await resolveCdb(
548
+ {
549
+ targetInput: request.targetInput,
550
+ cdbFile: request.cdbFile,
551
+ cdbPassword: request.cdbPassword,
552
+ },
553
+ env,
554
+ config,
555
+ ));
556
+ const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
557
+ const format = resolveApiFormat(request, env, config);
542
558
  // `--raw` forces validation off (constitution: --raw waiver); otherwise the
543
559
  // inspect gate is on by default.
544
560
  const validate: ResolvedSetting<boolean> = raw
@@ -550,12 +566,14 @@ export async function resolveApiRequest(
550
566
  true,
551
567
  "validate",
552
568
  cdbResolution?.overrides.validate,
569
+ config,
553
570
  );
554
571
  const timeoutMs = resolveApiTimeout(
555
572
  request.timeout,
556
573
  env,
557
574
  via.value,
558
575
  cdbResolution?.overrides.timeoutMs,
576
+ config,
559
577
  );
560
578
  const macResolution = isIpTransport(via.value)
561
579
  ? await resolveMacTarget({
@@ -563,7 +581,10 @@ export async function resolveApiRequest(
563
581
  targetInput: request.targetInput,
564
582
  cdbTarget: cdbResolution?.target,
565
583
  env,
566
- policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
584
+ config,
585
+ policy: parseResolvePolicy(
586
+ request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
587
+ ),
567
588
  // api has no Layer-2 transport (rest-api/native-api only), so MAC
568
589
  // remediation matches retrieve's (no mac-telnet suggestion).
569
590
  operation: "retrieve",
@@ -579,11 +600,13 @@ export async function resolveApiRequest(
579
600
  env,
580
601
  via.value,
581
602
  cdbResolution,
603
+ config,
582
604
  );
583
605
  const auth = resolveAuth(
584
606
  { username: request.username, password: request.password },
585
607
  env,
586
608
  cdbResolution,
609
+ config,
587
610
  );
588
611
  const insecure = resolveBooleanSetting(
589
612
  request.insecure,
@@ -592,6 +615,7 @@ export async function resolveApiRequest(
592
615
  false,
593
616
  "insecure",
594
617
  cdbResolution?.overrides.insecure,
618
+ config,
595
619
  );
596
620
 
597
621
  return {
@@ -1082,6 +1106,7 @@ function resolveApiProtocol(
1082
1106
  env: Record<string, string | undefined>,
1083
1107
  listen: boolean,
1084
1108
  cdb?: CdbResolution,
1109
+ config: Record<string, string | undefined> = {},
1085
1110
  ): ResolvedSetting<RouterOsProtocol> {
1086
1111
  // A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
1087
1112
  // REST stays the default for one-shot calls.
@@ -1094,6 +1119,7 @@ function resolveApiProtocol(
1094
1119
  "via",
1095
1120
  undefined,
1096
1121
  cdb?.overrides.via,
1122
+ config,
1097
1123
  );
1098
1124
  if (!via) {
1099
1125
  throw new CentrsError({
@@ -1102,29 +1128,42 @@ function resolveApiProtocol(
1102
1128
  remediation: "Report this bug; api should default to rest-api.",
1103
1129
  });
1104
1130
  }
1105
- if (!plannedProtocols.includes(via.value as RouterOsProtocol)) {
1131
+ assertApiProtocolSupported(via.value);
1132
+ return via as ResolvedSetting<RouterOsProtocol>;
1133
+ }
1134
+
1135
+ /**
1136
+ * Reject a protocol identifier `api` cannot run over. Throws `settings/invalid-via`
1137
+ * for an unknown protocol and `routeros/protocol-not-implemented` for a known
1138
+ * transport (e.g. ssh / mac-telnet) that `api` does not support. Shared so a
1139
+ * globally-pinned `--via` / `CENTRS_VIA` can be rejected as an orchestration
1140
+ * pre-flight error in fan-out (`api-fanout.ts`) rather than degrading into N
1141
+ * identical per-target resolve failures.
1142
+ */
1143
+ export function assertApiProtocolSupported(via: string): void {
1144
+ if (!plannedProtocols.includes(via as RouterOsProtocol)) {
1106
1145
  throw new CentrsError({
1107
1146
  code: "settings/invalid-via",
1108
- summary: `Unsupported protocol identifier: ${via.value}`,
1147
+ summary: `Unsupported protocol identifier: ${via}`,
1109
1148
  remediation: "`api` runs over `rest-api` (default) or `native-api`.",
1110
- context: { via: via.value },
1149
+ context: { via },
1111
1150
  });
1112
1151
  }
1113
- if (via.value !== "rest-api" && via.value !== "native-api") {
1152
+ if (via !== "rest-api" && via !== "native-api") {
1114
1153
  throw new CentrsError({
1115
1154
  code: "routeros/protocol-not-implemented",
1116
- summary: `api over ${via.value} is not supported.`,
1155
+ summary: `api over ${via} is not supported.`,
1117
1156
  remediation:
1118
1157
  "Use `--via rest-api` (default) or `--via native-api`. Console transports (ssh/mac-telnet) run through `execute`/`terminal`.",
1119
- context: { via: via.value, capability: "execute" },
1158
+ context: { via, capability: "execute" },
1120
1159
  });
1121
1160
  }
1122
- return via as ResolvedSetting<RouterOsProtocol>;
1123
1161
  }
1124
1162
 
1125
1163
  function resolveApiFormat(
1126
1164
  request: ApiRequest,
1127
1165
  env: Record<string, string | undefined>,
1166
+ config: Record<string, string | undefined> = {},
1128
1167
  ): ResolvedSetting<ApiOutputFormat> {
1129
1168
  return resolveStringSetting(
1130
1169
  request.format,
@@ -1133,6 +1172,8 @@ function resolveApiFormat(
1133
1172
  "json",
1134
1173
  "format",
1135
1174
  parseApiOutputFormat,
1175
+ undefined,
1176
+ config,
1136
1177
  ) as ResolvedSetting<ApiOutputFormat>;
1137
1178
  }
1138
1179
 
@@ -1141,6 +1182,7 @@ function resolveApiTimeout(
1141
1182
  env: Record<string, string | undefined>,
1142
1183
  via: RouterOsProtocol,
1143
1184
  commentKv?: ResolvedSetting<number>,
1185
+ config: Record<string, string | undefined> = {},
1144
1186
  ): ResolvedSetting<number> {
1145
1187
  const resolved = resolveStringSetting(
1146
1188
  timeout === undefined ? undefined : String(timeout),
@@ -1161,6 +1203,7 @@ function resolveApiTimeout(
1161
1203
  return parsed;
1162
1204
  },
1163
1205
  commentKv,
1206
+ config,
1164
1207
  );
1165
1208
  if (!resolved) {
1166
1209
  throw new Error("timeout resolution produced no value");
@@ -1402,7 +1445,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
1402
1445
  }
1403
1446
  }
1404
1447
 
1405
- function parseApiMethod(method: string | undefined): ApiMethod {
1448
+ export function parseApiMethod(method: string | undefined): ApiMethod {
1406
1449
  if (method === undefined) {
1407
1450
  return "GET";
1408
1451
  }
@@ -1418,6 +1461,35 @@ function parseApiMethod(method: string | undefined): ApiMethod {
1418
1461
  });
1419
1462
  }
1420
1463
 
1464
+ /**
1465
+ * Build the request-level summary (no target) for the fan-out outer meta and
1466
+ * for an empty selection where no per-target inner envelope exists. Mirrors the
1467
+ * summary `buildApiErrorEnvelope` assembles from a bare request.
1468
+ */
1469
+ export function apiRequestSummaryFromRequest(
1470
+ request: ApiRequest,
1471
+ env: Record<string, string | undefined> = Bun.env,
1472
+ ): ApiRequestSummary {
1473
+ const normalized = normalizeApiEndpoint(request.endpoint);
1474
+ const parsed = tryParseApiMethod(request.method);
1475
+ const listen = (request.listen ?? false) || normalized.listen;
1476
+ return {
1477
+ endpoint: request.endpoint,
1478
+ path: normalized.path,
1479
+ id: normalized.id,
1480
+ method: parsed ?? request.method ?? "GET",
1481
+ verb: parsed ? mapMethodToVerb(parsed) : null,
1482
+ write: parsed ? isApiMutating(parsed, normalized.path) : false,
1483
+ listen,
1484
+ yes: request.yes ?? false,
1485
+ validate: request.raw ? false : (request.validate ?? true),
1486
+ raw: request.raw ?? false,
1487
+ format: resolveErrorFormat(request, env),
1488
+ query: buildApiQuery(request),
1489
+ proplist: buildApiProplist(request),
1490
+ };
1491
+ }
1492
+
1421
1493
  /** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
1422
1494
  function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1423
1495
  if (method === undefined) {
package/src/cli/api.ts CHANGED
@@ -9,6 +9,12 @@ import {
9
9
  renderApiEnvelope,
10
10
  renderApiStreamLine,
11
11
  } from "../api.ts";
12
+ import {
13
+ apiFanout,
14
+ buildApiFanoutErrorEnvelope,
15
+ renderApiFanoutEnvelope,
16
+ } from "../api-fanout.ts";
17
+ import { fanoutExitCode } from "../core/fanout.ts";
12
18
  import {
13
19
  asCentrsError,
14
20
  CentrsError,
@@ -29,6 +35,14 @@ import {
29
35
  missingTargetError,
30
36
  withTips,
31
37
  } from "./missing-target.ts";
38
+ import {
39
+ buildTargetSelection,
40
+ consumeSelectionFlag,
41
+ emptySelectionFlags,
42
+ isFanoutMode,
43
+ type SelectionFlags,
44
+ selectionCommandOptions,
45
+ } from "./selection.ts";
32
46
 
33
47
  export const apiCommand: CliCommandMetadata = {
34
48
  name: "api",
@@ -106,6 +120,7 @@ export const apiCommand: CliCommandMetadata = {
106
120
  valueName: "<rest-api|native-api>",
107
121
  description: "Pin the transport; no silent downgrade. Default rest-api.",
108
122
  },
123
+ ...selectionCommandOptions,
109
124
  {
110
125
  flag: "--host",
111
126
  valueName: "<host|url>",
@@ -179,11 +194,16 @@ interface ApiCliArgs extends ApiRequest {
179
194
  format?: ApiOutputFormat;
180
195
  /** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
181
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[];
182
201
  }
183
202
 
184
203
  export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
185
204
  const parsed: ApiCliArgs = { endpoint: "" };
186
205
  const positional: string[] = [];
206
+ const selectionFlags = emptySelectionFlags();
187
207
 
188
208
  for (let index = 0; index < args.length; index += 1) {
189
209
  const arg = args[index];
@@ -313,17 +333,27 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
313
333
  parsed.format = value as ApiOutputFormat;
314
334
  break;
315
335
  }
316
- default:
336
+ default: {
337
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
338
+ if (consumed !== null) {
339
+ index = consumed;
340
+ break;
341
+ }
317
342
  if (arg.startsWith("-")) {
318
343
  throw unknownFlagError("api", arg, apiCommand.options);
319
344
  }
320
345
  positional.push(arg);
321
346
  break;
347
+ }
322
348
  }
323
349
  }
324
350
 
325
- parsed.targetInput = positional[0];
326
- parsed.endpoint = positional[1] ?? "";
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];
327
357
  parsed.stdinIsTty = process.stdin.isTTY;
328
358
  return parsed;
329
359
  }
@@ -336,14 +366,6 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
336
366
  console.log(renderCommandHelp(describeCentrs(), apiCommand));
337
367
  return 0;
338
368
  }
339
- if (!parsed.targetInput) {
340
- throw missingTargetError({
341
- command: "api",
342
- summary: "`centrs api` requires a <router> and an <endpoint>.",
343
- remediation:
344
- "Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
345
- });
346
- }
347
369
  // Resolve `--input` (file / stdin) before handing the body to the orchestrator.
348
370
  if (parsed.inputPath !== undefined) {
349
371
  const inputPath = parsed.inputPath;
@@ -364,6 +386,20 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
364
386
  }
365
387
  }
366
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
+
367
403
  // Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
368
404
  // the NDJSON envelope stream instead of a single one-shot envelope.
369
405
  const streaming =
@@ -374,6 +410,15 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
374
410
  return await runApiListenCli(parsed, args);
375
411
  }
376
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
+
377
422
  const envelope = await apiEnvelope(parsed);
378
423
  const resolvedFormat =
379
424
  (
@@ -407,6 +452,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
407
452
  // missing `--input` file) instead of falling back to plain text.
408
453
  const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
409
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).
410
456
  renderApiEnvelope(envelope, format, {
411
457
  raw: true,
412
458
  verbose: parsed.verbose ?? false,
@@ -418,12 +464,14 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
418
464
  tips,
419
465
  );
420
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).
421
468
  renderApiEnvelope(envelope, format, {
422
469
  verbose: parsed?.verbose ?? false,
423
470
  }),
424
471
  );
425
472
  } else {
426
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).
427
475
  formatCentrsErrorText(
428
476
  asCentrsError(error, {
429
477
  code: "input/invalid-command",
@@ -439,6 +487,82 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
439
487
  }
440
488
  }
441
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
+
442
566
  /**
443
567
  * Consume the open-ended `--stream` follow: print one line per change frame
444
568
  * (NDJSON under `json`, human rows under `text`), then the terminating summary.
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
  );
package/src/cli/common.ts CHANGED
@@ -33,6 +33,17 @@ export function expectValue(
33
33
  return value;
34
34
  }
35
35
 
36
+ /**
37
+ * Parse a strictly-integer flag value, or `undefined` when the whole token is
38
+ * not an integer. Rejects numeric prefixes (`2abc` → undefined, not `2`) and
39
+ * decimals (`1.5`), which a bare `Number.parseInt` would silently truncate —
40
+ * the trap shared by `--port` / `--count` / `--concurrency`.
41
+ */
42
+ export function parseStrictInteger(value: string): number | undefined {
43
+ const trimmed = value.trim();
44
+ return /^-?\d+$/.test(trimmed) ? Number.parseInt(trimmed, 10) : undefined;
45
+ }
46
+
36
47
  /** Flatten an option's `flag` field ("-X / --method") into individual `-`/`--` tokens. */
37
48
  export function knownFlags(options: readonly CliCommandOption[]): string[] {
38
49
  const flags: string[] = [];