@tikoci/centrs 0.1.2 → 0.1.4

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 (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
package/src/api.ts CHANGED
@@ -45,10 +45,13 @@ import {
45
45
  type RouterOsProtocol,
46
46
  } from "./protocols/index.ts";
47
47
  import {
48
+ assertNoQuickchrOverrideConflict,
48
49
  type CdbResolution,
49
50
  isIpTransport,
51
+ loadEnvFileDefaults,
50
52
  parseDuration,
51
53
  parseResolvePolicy,
54
+ quickchrConnection,
52
55
  type ResolvedAuth,
53
56
  type ResolvedSetting,
54
57
  type ResolvedTarget,
@@ -56,6 +59,7 @@ import {
56
59
  resolveBooleanSetting,
57
60
  resolveCdb,
58
61
  resolveMacTarget,
62
+ resolveQuickchrTarget,
59
63
  resolveStringSetting,
60
64
  resolveTarget,
61
65
  toCoreSource,
@@ -71,6 +75,12 @@ export type ApiMethod = (typeof apiMethods)[number];
71
75
 
72
76
  export interface ApiRequest {
73
77
  targetInput?: string;
78
+ /**
79
+ * quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
80
+ * the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
81
+ * Resolution providers). Conflicts with host/port/username/password.
82
+ */
83
+ quickchr?: string;
74
84
  /** REST-style endpoint path, leniently normalized (`ip/address`, `/rest/ip/address`, `"ip address"`, `ip/address/*1`, `ip/address/listen`). */
75
85
  endpoint: string;
76
86
  /** HTTP method (`-X`), default `GET`, case-insensitive. */
@@ -500,8 +510,20 @@ function disabledValidationMeta(): EnvelopeValidationMeta {
500
510
  export async function resolveApiRequest(
501
511
  request: ApiRequest,
502
512
  env: Record<string, string | undefined>,
513
+ override?: {
514
+ /**
515
+ * A pre-resolved CDB record, supplied by fan-out so the CDB is loaded ONCE
516
+ * (not per member) and a `--default`/literal member is never re-resolved
517
+ * through `resolveCdb`'s `__default__` synthetic-target fallback.
518
+ */
519
+ cdbResolution?: CdbResolution;
520
+ /** A pre-loaded `centrs.env` config tier, supplied by fan-out so the file
521
+ * is read ONCE (not per member). */
522
+ config?: Record<string, string | undefined>;
523
+ },
503
524
  ): Promise<ResolvedApiRequest> {
504
525
  validateApiRequestShape(request);
526
+ const config = override?.config ?? (await loadEnvFileDefaults(env));
505
527
  const normalized = normalizeApiEndpoint(request.endpoint);
506
528
  const method = parseApiMethod(request.method);
507
529
  const verb = mapMethodToVerb(method);
@@ -529,16 +551,29 @@ export async function resolveApiRequest(
529
551
  const query = buildApiQuery(request);
530
552
  const proplist = buildApiProplist(request);
531
553
 
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);
554
+ // A quickchr target is a named-live-provider: host/port/auth come from the
555
+ // live descriptor, bypassing CDB, `__default__`, and MAC resolution.
556
+ let quickchrResolution: Awaited<
557
+ ReturnType<typeof resolveQuickchrTarget>
558
+ > | null = null;
559
+ if (request.quickchr !== undefined) {
560
+ assertNoQuickchrOverrideConflict(request, request.quickchr);
561
+ quickchrResolution = await resolveQuickchrTarget(request.quickchr);
562
+ }
563
+ const cdbResolution = quickchrResolution
564
+ ? undefined
565
+ : (override?.cdbResolution ??
566
+ (await resolveCdb(
567
+ {
568
+ targetInput: request.targetInput,
569
+ cdbFile: request.cdbFile,
570
+ cdbPassword: request.cdbPassword,
571
+ },
572
+ env,
573
+ config,
574
+ )));
575
+ const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
576
+ const format = resolveApiFormat(request, env, config);
542
577
  // `--raw` forces validation off (constitution: --raw waiver); otherwise the
543
578
  // inspect gate is on by default.
544
579
  const validate: ResolvedSetting<boolean> = raw
@@ -550,49 +585,79 @@ export async function resolveApiRequest(
550
585
  true,
551
586
  "validate",
552
587
  cdbResolution?.overrides.validate,
588
+ config,
553
589
  );
554
590
  const timeoutMs = resolveApiTimeout(
555
591
  request.timeout,
556
592
  env,
557
593
  via.value,
558
594
  cdbResolution?.overrides.timeoutMs,
595
+ config,
559
596
  );
560
- const macResolution = isIpTransport(via.value)
561
- ? await resolveMacTarget({
562
- host: request.host,
563
- targetInput: request.targetInput,
564
- cdbTarget: cdbResolution?.target,
597
+ const macResolution =
598
+ quickchrResolution === null && isIpTransport(via.value)
599
+ ? await resolveMacTarget({
600
+ host: request.host,
601
+ targetInput: request.targetInput,
602
+ cdbTarget: cdbResolution?.target,
603
+ env,
604
+ config,
605
+ policy: parseResolvePolicy(
606
+ request.resolve ??
607
+ env["CENTRS_RESOLVE"] ??
608
+ config["CENTRS_RESOLVE"],
609
+ ),
610
+ // api has no Layer-2 transport (rest-api/native-api only), so MAC
611
+ // remediation matches retrieve's (no mac-telnet suggestion).
612
+ operation: "retrieve",
613
+ })
614
+ : undefined;
615
+ const connection = quickchrResolution
616
+ ? quickchrConnection(quickchrResolution, via.value)
617
+ : null;
618
+ const target = connection
619
+ ? connection.target
620
+ : resolveTarget(
621
+ {
622
+ targetInput: request.targetInput,
623
+ host: request.host,
624
+ port: request.port,
625
+ macResolution,
626
+ },
565
627
  env,
566
- policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
567
- // api has no Layer-2 transport (rest-api/native-api only), so MAC
568
- // remediation matches retrieve's (no mac-telnet suggestion).
569
- operation: "retrieve",
570
- })
571
- : undefined;
572
- const target = resolveTarget(
573
- {
574
- targetInput: request.targetInput,
575
- host: request.host,
576
- port: request.port,
577
- macResolution,
578
- },
579
- env,
580
- via.value,
581
- cdbResolution,
582
- );
583
- const auth = resolveAuth(
584
- { username: request.username, password: request.password },
585
- env,
586
- cdbResolution,
587
- );
588
- const insecure = resolveBooleanSetting(
589
- request.insecure,
590
- env,
591
- "CENTRS_INSECURE",
592
- false,
593
- "insecure",
594
- cdbResolution?.overrides.insecure,
595
- );
628
+ via.value,
629
+ cdbResolution,
630
+ config,
631
+ );
632
+ const auth = connection
633
+ ? connection.auth
634
+ : resolveAuth(
635
+ { username: request.username, password: request.password },
636
+ env,
637
+ cdbResolution,
638
+ config,
639
+ );
640
+ // A quickchr TLS endpoint is trusted by provenance (self-signed VM cert) —
641
+ // `insecure` is forced with provider provenance and the provider-trust
642
+ // warning rides the envelope (see QuickchrConnection.insecure).
643
+ const insecure =
644
+ connection?.insecure === true
645
+ ? {
646
+ value: true,
647
+ source: {
648
+ kind: "provider" as const,
649
+ key: `quickchr:${request.quickchr}`,
650
+ },
651
+ }
652
+ : resolveBooleanSetting(
653
+ request.insecure,
654
+ env,
655
+ "CENTRS_INSECURE",
656
+ false,
657
+ "insecure",
658
+ cdbResolution?.overrides.insecure,
659
+ config,
660
+ );
596
661
 
597
662
  return {
598
663
  endpoint: request.endpoint,
@@ -620,7 +685,9 @@ export async function resolveApiRequest(
620
685
  insecure,
621
686
  yes: request.yes ?? false,
622
687
  verbose: request.verbose ?? false,
623
- warnings: cdbResolution?.warnings ?? [],
688
+ warnings: connection
689
+ ? [...connection.warnings]
690
+ : (cdbResolution?.warnings ?? []),
624
691
  };
625
692
  }
626
693
 
@@ -918,7 +985,7 @@ async function validateApiRequest(
918
985
  context: {
919
986
  path: resolved.path,
920
987
  verb: resolved.verb,
921
- attribute: missing[0],
988
+ parameter: missing[0],
922
989
  requestedAttributes: requested,
923
990
  availableAttributes: available,
924
991
  validationSource: "/console/inspect request=child+completion",
@@ -963,7 +1030,7 @@ async function validateApiRequest(
963
1030
  context: {
964
1031
  path: resolved.path,
965
1032
  verb: resolved.verb,
966
- attribute: missing[0],
1033
+ parameter: missing[0],
967
1034
  requestedAttributes: requested,
968
1035
  availableAttributes: available,
969
1036
  validationSource: "/console/inspect request=child+completion",
@@ -1082,6 +1149,7 @@ function resolveApiProtocol(
1082
1149
  env: Record<string, string | undefined>,
1083
1150
  listen: boolean,
1084
1151
  cdb?: CdbResolution,
1152
+ config: Record<string, string | undefined> = {},
1085
1153
  ): ResolvedSetting<RouterOsProtocol> {
1086
1154
  // A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
1087
1155
  // REST stays the default for one-shot calls.
@@ -1094,6 +1162,7 @@ function resolveApiProtocol(
1094
1162
  "via",
1095
1163
  undefined,
1096
1164
  cdb?.overrides.via,
1165
+ config,
1097
1166
  );
1098
1167
  if (!via) {
1099
1168
  throw new CentrsError({
@@ -1102,29 +1171,42 @@ function resolveApiProtocol(
1102
1171
  remediation: "Report this bug; api should default to rest-api.",
1103
1172
  });
1104
1173
  }
1105
- if (!plannedProtocols.includes(via.value as RouterOsProtocol)) {
1174
+ assertApiProtocolSupported(via.value);
1175
+ return via as ResolvedSetting<RouterOsProtocol>;
1176
+ }
1177
+
1178
+ /**
1179
+ * Reject a protocol identifier `api` cannot run over. Throws `settings/invalid-via`
1180
+ * for an unknown protocol and `routeros/protocol-not-implemented` for a known
1181
+ * transport (e.g. ssh / mac-telnet) that `api` does not support. Shared so a
1182
+ * globally-pinned `--via` / `CENTRS_VIA` can be rejected as an orchestration
1183
+ * pre-flight error in fan-out (`api-fanout.ts`) rather than degrading into N
1184
+ * identical per-target resolve failures.
1185
+ */
1186
+ export function assertApiProtocolSupported(via: string): void {
1187
+ if (!plannedProtocols.includes(via as RouterOsProtocol)) {
1106
1188
  throw new CentrsError({
1107
1189
  code: "settings/invalid-via",
1108
- summary: `Unsupported protocol identifier: ${via.value}`,
1190
+ summary: `Unsupported protocol identifier: ${via}`,
1109
1191
  remediation: "`api` runs over `rest-api` (default) or `native-api`.",
1110
- context: { via: via.value },
1192
+ context: { via },
1111
1193
  });
1112
1194
  }
1113
- if (via.value !== "rest-api" && via.value !== "native-api") {
1195
+ if (via !== "rest-api" && via !== "native-api") {
1114
1196
  throw new CentrsError({
1115
1197
  code: "routeros/protocol-not-implemented",
1116
- summary: `api over ${via.value} is not supported.`,
1198
+ summary: `api over ${via} is not supported.`,
1117
1199
  remediation:
1118
1200
  "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" },
1201
+ context: { via, capability: "execute" },
1120
1202
  });
1121
1203
  }
1122
- return via as ResolvedSetting<RouterOsProtocol>;
1123
1204
  }
1124
1205
 
1125
1206
  function resolveApiFormat(
1126
1207
  request: ApiRequest,
1127
1208
  env: Record<string, string | undefined>,
1209
+ config: Record<string, string | undefined> = {},
1128
1210
  ): ResolvedSetting<ApiOutputFormat> {
1129
1211
  return resolveStringSetting(
1130
1212
  request.format,
@@ -1133,6 +1215,8 @@ function resolveApiFormat(
1133
1215
  "json",
1134
1216
  "format",
1135
1217
  parseApiOutputFormat,
1218
+ undefined,
1219
+ config,
1136
1220
  ) as ResolvedSetting<ApiOutputFormat>;
1137
1221
  }
1138
1222
 
@@ -1141,6 +1225,7 @@ function resolveApiTimeout(
1141
1225
  env: Record<string, string | undefined>,
1142
1226
  via: RouterOsProtocol,
1143
1227
  commentKv?: ResolvedSetting<number>,
1228
+ config: Record<string, string | undefined> = {},
1144
1229
  ): ResolvedSetting<number> {
1145
1230
  const resolved = resolveStringSetting(
1146
1231
  timeout === undefined ? undefined : String(timeout),
@@ -1161,6 +1246,7 @@ function resolveApiTimeout(
1161
1246
  return parsed;
1162
1247
  },
1163
1248
  commentKv,
1249
+ config,
1164
1250
  );
1165
1251
  if (!resolved) {
1166
1252
  throw new Error("timeout resolution produced no value");
@@ -1402,7 +1488,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
1402
1488
  }
1403
1489
  }
1404
1490
 
1405
- function parseApiMethod(method: string | undefined): ApiMethod {
1491
+ export function parseApiMethod(method: string | undefined): ApiMethod {
1406
1492
  if (method === undefined) {
1407
1493
  return "GET";
1408
1494
  }
@@ -1418,6 +1504,35 @@ function parseApiMethod(method: string | undefined): ApiMethod {
1418
1504
  });
1419
1505
  }
1420
1506
 
1507
+ /**
1508
+ * Build the request-level summary (no target) for the fan-out outer meta and
1509
+ * for an empty selection where no per-target inner envelope exists. Mirrors the
1510
+ * summary `buildApiErrorEnvelope` assembles from a bare request.
1511
+ */
1512
+ export function apiRequestSummaryFromRequest(
1513
+ request: ApiRequest,
1514
+ env: Record<string, string | undefined> = Bun.env,
1515
+ ): ApiRequestSummary {
1516
+ const normalized = normalizeApiEndpoint(request.endpoint);
1517
+ const parsed = tryParseApiMethod(request.method);
1518
+ const listen = (request.listen ?? false) || normalized.listen;
1519
+ return {
1520
+ endpoint: request.endpoint,
1521
+ path: normalized.path,
1522
+ id: normalized.id,
1523
+ method: parsed ?? request.method ?? "GET",
1524
+ verb: parsed ? mapMethodToVerb(parsed) : null,
1525
+ write: parsed ? isApiMutating(parsed, normalized.path) : false,
1526
+ listen,
1527
+ yes: request.yes ?? false,
1528
+ validate: request.raw ? false : (request.validate ?? true),
1529
+ raw: request.raw ?? false,
1530
+ format: resolveErrorFormat(request, env),
1531
+ query: buildApiQuery(request),
1532
+ proplist: buildApiProplist(request),
1533
+ };
1534
+ }
1535
+
1421
1536
  /** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
1422
1537
  function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1423
1538
  if (method === undefined) {
package/src/cli/api.ts CHANGED
@@ -9,12 +9,19 @@ 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,
15
21
  formatCentrsErrorText,
16
22
  } from "../errors.ts";
17
23
  import { describeCentrs } from "../index.ts";
24
+ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
18
25
  import {
19
26
  type CliCommandMetadata,
20
27
  expectValue,
@@ -29,6 +36,15 @@ import {
29
36
  missingTargetError,
30
37
  withTips,
31
38
  } from "./missing-target.ts";
39
+ import {
40
+ assertQuickchrExclusive,
41
+ buildTargetSelection,
42
+ consumeSelectionFlag,
43
+ emptySelectionFlags,
44
+ isFanoutMode,
45
+ type SelectionFlags,
46
+ selectionCommandOptions,
47
+ } from "./selection.ts";
32
48
 
33
49
  export const apiCommand: CliCommandMetadata = {
34
50
  name: "api",
@@ -43,7 +59,7 @@ export const apiCommand: CliCommandMetadata = {
43
59
  "HTTP method, default GET (case-insensitive). GET→print, PUT→add, PATCH→set, DELETE→remove, POST→run.",
44
60
  },
45
61
  {
46
- flag: "-f",
62
+ flag: "-f / --field",
47
63
  valueName: "<key=value>",
48
64
  description:
49
65
  "Body field, repeatable; assembled into the JSON body (verbatim string values).",
@@ -106,6 +122,7 @@ export const apiCommand: CliCommandMetadata = {
106
122
  valueName: "<rest-api|native-api>",
107
123
  description: "Pin the transport; no silent downgrade. Default rest-api.",
108
124
  },
125
+ ...selectionCommandOptions,
109
126
  {
110
127
  flag: "--host",
111
128
  valueName: "<host|url>",
@@ -153,9 +170,9 @@ export const apiCommand: CliCommandMetadata = {
153
170
  description: "Per-request timeout (for REST, max 60s).",
154
171
  },
155
172
  {
156
- flag: "--validate[=false]",
173
+ flag: "--validate / --no-validate",
157
174
  description:
158
- "Run `/console/inspect` validation before the request (default true).",
175
+ "Run `/console/inspect` validation before the request (default true; `--validate=false` also accepted).",
159
176
  },
160
177
  {
161
178
  flag: "--format",
@@ -179,11 +196,16 @@ interface ApiCliArgs extends ApiRequest {
179
196
  format?: ApiOutputFormat;
180
197
  /** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
181
198
  inputPath?: string;
199
+ /** Parsed target-selection flags (fan-out grammar). */
200
+ selectionFlags?: SelectionFlags;
201
+ /** Positionals before the endpoint — the fan-out target list. */
202
+ targetPositionals?: string[];
182
203
  }
183
204
 
184
205
  export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
185
206
  const parsed: ApiCliArgs = { endpoint: "" };
186
207
  const positional: string[] = [];
208
+ const selectionFlags = emptySelectionFlags();
187
209
 
188
210
  for (let index = 0; index < args.length; index += 1) {
189
211
  const arg = args[index];
@@ -313,17 +335,27 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
313
335
  parsed.format = value as ApiOutputFormat;
314
336
  break;
315
337
  }
316
- default:
338
+ default: {
339
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
340
+ if (consumed !== null) {
341
+ index = consumed;
342
+ break;
343
+ }
317
344
  if (arg.startsWith("-")) {
318
345
  throw unknownFlagError("api", arg, apiCommand.options);
319
346
  }
320
347
  positional.push(arg);
321
348
  break;
349
+ }
322
350
  }
323
351
  }
324
352
 
325
- parsed.targetInput = positional[0];
326
- parsed.endpoint = positional[1] ?? "";
353
+ // api/retrieve positional boundary: the FINAL positional is the endpoint; every
354
+ // preceding positional is a fan-out target.
355
+ parsed.endpoint = positional.at(-1) ?? "";
356
+ parsed.targetPositionals = positional.slice(0, -1);
357
+ parsed.selectionFlags = selectionFlags;
358
+ parsed.targetInput = parsed.targetPositionals[0];
327
359
  parsed.stdinIsTty = process.stdin.isTTY;
328
360
  return parsed;
329
361
  }
@@ -336,14 +368,20 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
336
368
  console.log(renderCommandHelp(describeCentrs(), apiCommand));
337
369
  return 0;
338
370
  }
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
- });
371
+ // Selection-shape usage errors fire before `--input` is read: a bad
372
+ // `--quickchr` combination must not block on stdin (`--input -`) or
373
+ // degrade into per-member fan-out failures. The resolver re-checks the
374
+ // override conflict for library callers.
375
+ const selectionFlags = parsed.selectionFlags ?? emptySelectionFlags();
376
+ const targetPositionals = parsed.targetPositionals ?? [];
377
+ assertQuickchrExclusive(selectionFlags, targetPositionals.length);
378
+ if (selectionFlags.quickchr.length > 0) {
379
+ assertNoQuickchrOverrideConflict(
380
+ parsed,
381
+ selectionFlags.quickchr[0] ?? "",
382
+ );
346
383
  }
384
+
347
385
  // Resolve `--input` (file / stdin) before handing the body to the orchestrator.
348
386
  if (parsed.inputPath !== undefined) {
349
387
  const inputPath = parsed.inputPath;
@@ -364,6 +402,23 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
364
402
  }
365
403
  }
366
404
 
405
+ // Fan-out mode (selector flag present, or >1 positional target) has its own
406
+ // envelope shape, guards, and granular exit code. It runs first and rejects
407
+ // `--stream`/`--listen` + fan-out itself (single-session is exclusive).
408
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
409
+ return await runApiFanoutCli(
410
+ parsed,
411
+ selectionFlags,
412
+ targetPositionals,
413
+ args,
414
+ );
415
+ }
416
+ // A single `--quickchr <name>` is single-target mode (streaming included):
417
+ // the machine name is the target, resolved from the live descriptor.
418
+ if (selectionFlags.quickchr.length === 1) {
419
+ parsed.quickchr = selectionFlags.quickchr[0];
420
+ }
421
+
367
422
  // Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
368
423
  // the NDJSON envelope stream instead of a single one-shot envelope.
369
424
  const streaming =
@@ -374,6 +429,15 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
374
429
  return await runApiListenCli(parsed, args);
375
430
  }
376
431
 
432
+ if (!parsed.targetInput && parsed.quickchr === undefined) {
433
+ throw missingTargetError({
434
+ command: "api",
435
+ summary: "`centrs api` requires a <router> and an <endpoint>.",
436
+ remediation:
437
+ "Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
438
+ });
439
+ }
440
+
377
441
  const envelope = await apiEnvelope(parsed);
378
442
  const resolvedFormat =
379
443
  (
@@ -407,6 +471,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
407
471
  // missing `--input` file) instead of falling back to plain text.
408
472
  const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
409
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).
410
475
  renderApiEnvelope(envelope, format, {
411
476
  raw: true,
412
477
  verbose: parsed.verbose ?? false,
@@ -418,12 +483,14 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
418
483
  tips,
419
484
  );
420
485
  console.error(
486
+ // 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
487
  renderApiEnvelope(envelope, format, {
422
488
  verbose: parsed?.verbose ?? false,
423
489
  }),
424
490
  );
425
491
  } else {
426
492
  console.error(
493
+ // 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
494
  formatCentrsErrorText(
428
495
  asCentrsError(error, {
429
496
  code: "input/invalid-command",
@@ -439,6 +506,82 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
439
506
  }
440
507
  }
441
508
 
509
+ async function runApiFanoutCli(
510
+ parsed: ApiCliArgs,
511
+ selectionFlags: SelectionFlags,
512
+ targetPositionals: readonly string[],
513
+ args: readonly string[],
514
+ ): Promise<number> {
515
+ const selection = buildTargetSelection(selectionFlags, targetPositionals);
516
+ const format = inferApiFormat(args, parsed);
517
+ try {
518
+ // `--raw` strips the envelope; incompatible with per-target envelopes.
519
+ if (parsed.raw) {
520
+ throw new CentrsError({
521
+ code: "usage/conflicting-flags",
522
+ summary:
523
+ "`--raw` cannot combine with a multi-target selection; fan-out needs per-target envelopes.",
524
+ remediation:
525
+ "Drop `--raw` to fan out, or target a single router for the bare RouterOS body.",
526
+ context: { flags: ["--raw", "fanout"] },
527
+ });
528
+ }
529
+ // `--listen`/`--stream` is single-session.
530
+ const listenRequested =
531
+ (parsed.listen ?? false) || endpointInfersListen(parsed.endpoint);
532
+ if (listenRequested) {
533
+ throw new CentrsError({
534
+ code: "usage/fanout-not-supported",
535
+ summary:
536
+ "`api --stream`/`--listen` is single-session and cannot fan out across multiple targets.",
537
+ remediation:
538
+ "Follow a stream against a single router (no `--group`/`--where`/`--all`/`--default`/multiple positionals).",
539
+ context: { capability: "listen" },
540
+ });
541
+ }
542
+ const envelope = await apiFanout(
543
+ parsed,
544
+ selection,
545
+ Bun.env,
546
+ {},
547
+ {
548
+ concurrency: selectionFlags.concurrency,
549
+ allowAdhoc: true,
550
+ },
551
+ );
552
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
553
+ console.log(
554
+ renderApiFanoutEnvelope(envelope, resolvedFormat, {
555
+ verbose: parsed.verbose ?? false,
556
+ }),
557
+ );
558
+ return fanoutExitCode(envelope);
559
+ } catch (error) {
560
+ const envelope = buildApiFanoutErrorEnvelope(
561
+ parsed,
562
+ selection,
563
+ error,
564
+ Bun.env,
565
+ );
566
+ console.error(
567
+ // 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.
568
+ renderApiFanoutEnvelope(envelope, format, {
569
+ verbose: parsed.verbose ?? false,
570
+ }),
571
+ );
572
+ return 1;
573
+ }
574
+ }
575
+
576
+ /** Whether an endpoint's trailing segment infers `--listen` (without throwing). */
577
+ function endpointInfersListen(endpoint: string): boolean {
578
+ try {
579
+ return normalizeApiEndpoint(endpoint).listen;
580
+ } catch {
581
+ return false;
582
+ }
583
+ }
584
+
442
585
  /**
443
586
  * Consume the open-ended `--stream` follow: print one line per change frame
444
587
  * (NDJSON under `json`, human rows under `text`), then the terminating summary.