@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
@@ -4,23 +4,30 @@
4
4
  * unchanged from the former monolithic `cli.ts`.
5
5
  */
6
6
 
7
- import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
7
+ import {
8
+ asCentrsError,
9
+ CentrsError,
10
+ formatCentrsErrorText,
11
+ } from "../errors.ts";
8
12
  import {
9
13
  buildRetrieveErrorEnvelope,
10
14
  buildRetrieveFanoutErrorEnvelope,
11
15
  describeCentrs,
16
+ fanoutExitCode,
12
17
  type RetrieveOutputFormat,
13
18
  type RetrieveRequest,
14
19
  renderRetrieveEnvelope,
15
20
  renderRetrieveFanoutEnvelope,
16
21
  retrieve,
17
- retrieveGroup,
22
+ retrieveFanout,
18
23
  retrieveOutputFormats,
19
24
  } from "../index.ts";
25
+ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
20
26
  import {
21
27
  type CliCommandMetadata,
22
28
  expectValue,
23
29
  renderCommandHelp,
30
+ unknownFlagError,
24
31
  } from "./common.ts";
25
32
  import {
26
33
  buildTargetSelectionTips,
@@ -30,11 +37,20 @@ import {
30
37
  missingTargetError,
31
38
  withTips,
32
39
  } from "./missing-target.ts";
40
+ import {
41
+ assertQuickchrExclusive,
42
+ buildTargetSelection,
43
+ consumeSelectionFlag,
44
+ emptySelectionFlags,
45
+ isFanoutMode,
46
+ type SelectionFlags,
47
+ selectionCommandOptions,
48
+ } from "./selection.ts";
33
49
 
34
50
  export const retrieveCommand: CliCommandMetadata = {
35
51
  name: "retrieve",
36
52
  usage:
37
- "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
53
+ "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
38
54
  summary:
39
55
  "Read RouterOS values through the shared core using the selected protocol.",
40
56
  options: [
@@ -44,18 +60,7 @@ export const retrieveCommand: CliCommandMetadata = {
44
60
  description:
45
61
  "Pin the protocol selector. Defaults to `rest-api` for retrieve.",
46
62
  },
47
- {
48
- flag: "--group",
49
- valueName: "<name>",
50
- description:
51
- "Fan out over every CDB record in the group. Replaces the <target> positional.",
52
- },
53
- {
54
- flag: "--concurrency",
55
- valueName: "<n>",
56
- description:
57
- "Bounded worker-pool size for `--group` fanout (REST 8, native-api 4 by default).",
58
- },
63
+ ...selectionCommandOptions,
59
64
  {
60
65
  flag: "--host",
61
66
  valueName: "<host|url>",
@@ -98,10 +103,16 @@ export const retrieveCommand: CliCommandMetadata = {
98
103
  description: "Request the RouterOS detail/all-attributes shape.",
99
104
  },
100
105
  {
101
- flag: "--list-attributes",
106
+ flag: "--list-attributes / --list",
102
107
  description:
103
108
  "List inspect-derived attributes without running the data call.",
104
109
  },
110
+ {
111
+ flag: "--query / --filter",
112
+ valueName: "<expr>",
113
+ description:
114
+ "RouterOS-side row filter (maps to `.query`). Not implemented yet — returns `validation/not-implemented`.",
115
+ },
105
116
  {
106
117
  flag: "--format",
107
118
  valueName: `<${retrieveOutputFormats.join("|")}>`,
@@ -145,37 +156,50 @@ export const retrieveCommand: CliCommandMetadata = {
145
156
  ],
146
157
  };
147
158
 
159
+ type RetrieveCliArgs = RetrieveRequest & {
160
+ help?: boolean;
161
+ selectionFlags?: SelectionFlags;
162
+ targetPositionals?: readonly string[];
163
+ };
164
+
148
165
  export async function runRetrieveCli(args: readonly string[]): Promise<number> {
149
- let request: RetrieveRequest | undefined;
166
+ let request: RetrieveCliArgs | undefined;
150
167
 
151
168
  try {
152
169
  request = parseRetrieveCliArgs(args);
153
- if ((request as { help?: boolean }).help) {
170
+ if (request.help) {
154
171
  console.log(renderCommandHelp(describeCentrs(), retrieveCommand));
155
172
  return 0;
156
173
  }
157
174
 
158
- const envelope = request.group
159
- ? await retrieveGroup(request)
160
- : await retrieve(request);
175
+ // Fan-out mode (a selector flag present, or >1 positional target) has its own
176
+ // envelope shape and granular exit code; a plain single-target call keeps the
177
+ // single-target envelope.
178
+ const selectionFlags = request.selectionFlags ?? emptySelectionFlags();
179
+ const targetPositionals = request.targetPositionals ?? [];
180
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
181
+ return await runRetrieveFanoutCli(
182
+ request,
183
+ selectionFlags,
184
+ targetPositionals,
185
+ args,
186
+ );
187
+ }
188
+
189
+ const envelope = await retrieve(request);
161
190
  const resolvedFormat =
162
191
  (
163
192
  envelope.meta.operation as {
164
193
  request?: { format?: RetrieveOutputFormat };
165
194
  }
166
195
  )?.request?.format ?? "text";
167
- const output = request.group
168
- ? renderRetrieveFanoutEnvelope(
169
- envelope as Parameters<typeof renderRetrieveFanoutEnvelope>[0],
170
- resolvedFormat,
171
- { verbose: request.verbose },
172
- )
173
- : renderRetrieveEnvelope(
174
- envelope as Parameters<typeof renderRetrieveEnvelope>[0],
175
- resolvedFormat,
176
- { verbose: request.verbose },
177
- );
178
- console.log(output);
196
+ console.log(
197
+ renderRetrieveEnvelope(
198
+ envelope as Parameters<typeof renderRetrieveEnvelope>[0],
199
+ resolvedFormat,
200
+ { verbose: request.verbose },
201
+ ),
202
+ );
179
203
  return 0;
180
204
  } catch (error) {
181
205
  const format = inferRequestedFormat(args, request);
@@ -186,31 +210,23 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
186
210
  })
187
211
  : [];
188
212
  if (format === "json" || format === "yaml") {
189
- if (request?.group) {
190
- const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
191
- console.error(
192
- renderRetrieveFanoutEnvelope(envelope, format, {
193
- verbose: request?.verbose ?? false,
194
- }),
195
- );
196
- } else {
197
- // When parsing failed before a request existed, build the error
198
- // envelope from an empty request rather than reconstructing positionals
199
- // from raw args — a credential value (e.g. the token after
200
- // `--password` / `--cdb-password`) would otherwise be echoed as
201
- // `meta.target.input`.
202
- const envelope = withTips(
203
- buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
204
- tips,
205
- );
206
- console.error(
207
- renderRetrieveEnvelope(envelope, format, {
208
- verbose: request?.verbose ?? false,
209
- }),
210
- );
211
- }
213
+ // When parsing failed before a request existed, build the error envelope
214
+ // from an empty request rather than reconstructing positionals from raw
215
+ // args — a credential value (e.g. the token after `--password` /
216
+ // `--cdb-password`) would otherwise be echoed as `meta.target.input`.
217
+ const envelope = withTips(
218
+ buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
219
+ tips,
220
+ );
221
+ console.error(
222
+ // 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.
223
+ renderRetrieveEnvelope(envelope, format, {
224
+ verbose: request?.verbose ?? false,
225
+ }),
226
+ );
212
227
  } else {
213
228
  console.error(
229
+ // codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
214
230
  formatCentrsErrorText(
215
231
  asCentrsError(error, {
216
232
  code: "input/invalid-command",
@@ -228,12 +244,50 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
228
244
  }
229
245
  }
230
246
 
231
- function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
232
- help?: boolean;
233
- } {
234
- const request: RetrieveRequest & { help?: boolean } = {
247
+ async function runRetrieveFanoutCli(
248
+ request: RetrieveCliArgs,
249
+ selectionFlags: SelectionFlags,
250
+ targetPositionals: readonly string[],
251
+ args: readonly string[],
252
+ ): Promise<number> {
253
+ const selection = buildTargetSelection(selectionFlags, targetPositionals);
254
+ const format = inferRequestedFormat(args, request);
255
+ try {
256
+ const envelope = await retrieveFanout(
257
+ request,
258
+ selection,
259
+ Bun.env,
260
+ {},
261
+ {
262
+ concurrency: selectionFlags.concurrency,
263
+ allowAdhoc: true,
264
+ },
265
+ );
266
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
267
+ console.log(
268
+ renderRetrieveFanoutEnvelope(envelope, resolvedFormat, {
269
+ verbose: request.verbose ?? false,
270
+ }),
271
+ );
272
+ // Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
273
+ return fanoutExitCode(envelope);
274
+ } catch (error) {
275
+ const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
276
+ console.error(
277
+ // 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.
278
+ renderRetrieveFanoutEnvelope(envelope, format, {
279
+ verbose: request.verbose ?? false,
280
+ }),
281
+ );
282
+ return 1;
283
+ }
284
+ }
285
+
286
+ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
287
+ const request: RetrieveCliArgs = {
235
288
  path: "",
236
289
  };
290
+ const selectionFlags = emptySelectionFlags();
237
291
  const positional: string[] = [];
238
292
 
239
293
  for (let index = 0; index < args.length; index += 1) {
@@ -314,15 +368,6 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
314
368
  case "--resolve":
315
369
  request.resolve = expectValue(args, ++index, arg);
316
370
  break;
317
- case "--group":
318
- request.group = expectValue(args, ++index, arg);
319
- break;
320
- case "--concurrency":
321
- request.concurrency = Number.parseInt(
322
- expectValue(args, ++index, arg),
323
- 10,
324
- );
325
- break;
326
371
  case "--validate":
327
372
  request.validate = true;
328
373
  break;
@@ -332,35 +377,73 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
332
377
  case "--verbose":
333
378
  request.verbose = true;
334
379
  break;
335
- default:
380
+ default: {
381
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
382
+ if (consumed !== null) {
383
+ index = consumed;
384
+ break;
385
+ }
336
386
  if (arg.startsWith("-")) {
337
- throw new Error(`Unknown retrieve flag: ${arg}`);
387
+ throw unknownFlagError("retrieve", arg, retrieveCommand.options);
338
388
  }
339
389
  positional.push(arg);
340
390
  break;
391
+ }
341
392
  }
342
393
  }
343
394
 
395
+ request.selectionFlags = selectionFlags;
396
+
344
397
  if (request.help) {
345
398
  return request;
346
399
  }
347
400
 
348
- if (request.group !== undefined) {
349
- if (positional.length < 1) {
350
- throw new Error(
351
- "`centrs retrieve --group <name>` requires a <routeros-path>.",
352
- );
401
+ // api/retrieve positional boundary: the FINAL positional is the routeros-path;
402
+ // every preceding positional is a fan-out target.
403
+ const path = positional.at(-1) ?? "";
404
+ const targetPositionals = positional.slice(0, -1);
405
+ request.path = path;
406
+ request.targetPositionals = targetPositionals;
407
+ assertQuickchrExclusive(selectionFlags, targetPositionals.length);
408
+ // Direct connection overrides conflict with `--quickchr` globally: reject at
409
+ // parse time so a repeated `--quickchr` gets one usage error (exit 1), never
410
+ // per-member failures. The resolver re-checks for library callers.
411
+ if (selectionFlags.quickchr.length > 0) {
412
+ assertNoQuickchrOverrideConflict(request, selectionFlags.quickchr[0] ?? "");
413
+ }
414
+
415
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
416
+ if (positional.length === 0 || path.length === 0) {
417
+ throw new CentrsError({
418
+ code: "input/invalid-command",
419
+ summary:
420
+ "`centrs retrieve` fan-out requires a <routeros-path> after the selectors/targets.",
421
+ remediation:
422
+ "Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --group prod /system/resource`.",
423
+ context: { command: "retrieve", missingPath: true },
424
+ });
353
425
  }
354
- if (positional.length > 1) {
355
- throw new Error(
356
- "`centrs retrieve --group <name>` takes only a <routeros-path>; the group replaces the <target> positional.",
357
- );
426
+ return request;
427
+ }
428
+
429
+ // A single `--quickchr <name>` is single-target mode: the machine name is the
430
+ // target (resolved from the live descriptor inside the resolver); the lone
431
+ // positional is the routeros-path.
432
+ if (selectionFlags.quickchr.length === 1) {
433
+ if (positional.length === 0 || path.length === 0) {
434
+ throw new CentrsError({
435
+ code: "input/invalid-command",
436
+ summary: "`centrs retrieve --quickchr` requires a <routeros-path>.",
437
+ remediation:
438
+ "Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --quickchr lab /system/resource`.",
439
+ context: { command: "retrieve", missingPath: true },
440
+ });
358
441
  }
359
- request.path = positional[0] ?? "";
442
+ request.quickchr = selectionFlags.quickchr[0];
360
443
  return request;
361
444
  }
362
445
 
363
- if (positional.length === 0) {
446
+ if (positional.length === 0 || targetPositionals.length === 0) {
364
447
  throw missingTargetError({
365
448
  command: "retrieve",
366
449
  summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
@@ -368,14 +451,8 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
368
451
  "Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
369
452
  });
370
453
  }
371
- if (positional.length < 2) {
372
- throw new Error(
373
- "`centrs retrieve` requires a <routeros-path> after the <target>.",
374
- );
375
- }
376
-
377
- request.targetInput = positional[0];
378
- request.path = positional[1] ?? "";
454
+ // Single-target: exactly one target positional (more would be fan-out mode).
455
+ request.targetInput = targetPositionals[0];
379
456
  return request;
380
457
  }
381
458
 
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Shared target-selection flag parser.
3
+ *
4
+ * The fan-out grammar (`--group` / `--where` / `--all` / `--default` /
5
+ * `--concurrency`) is parsed ONCE here so every command's hand-rolled `switch`
6
+ * loop delegates to it instead of re-implementing the grammar. This helper owns
7
+ * ONLY the flag selectors — it does NOT consume positionals, because a
8
+ * positional means a different thing per command (endpoint / path / command /
9
+ * transfer verb + paths). Each command splits its own positionals into
10
+ * `(targetPositionals, operationArgs)` and hands the target positionals to
11
+ * {@link buildTargetSelection}. See `docs/CONSTITUTION.md` (Target selection).
12
+ */
13
+
14
+ import { CentrsError } from "../errors.ts";
15
+ import {
16
+ type BboxPredicate,
17
+ type NearPredicate,
18
+ parseBbox,
19
+ parseNear,
20
+ type SelectionWhereClause,
21
+ type TargetSelection,
22
+ } from "../resolver/index.ts";
23
+ import {
24
+ type CliCommandOption,
25
+ expectValue,
26
+ parseStrictInteger,
27
+ } from "./common.ts";
28
+
29
+ /**
30
+ * The shared target-selection flags, as command-help rows. Spread into each
31
+ * fan-out command's `options` so `<command> --help` lists them and the
32
+ * "did you mean?" suggester knows them — one source, so a future command can't
33
+ * forget them.
34
+ */
35
+ export const selectionCommandOptions: readonly CliCommandOption[] = [
36
+ {
37
+ flag: "--group",
38
+ valueName: "<name>",
39
+ description:
40
+ "Fan out across every CDB record in the group (repeatable; de-duped by record index).",
41
+ },
42
+ {
43
+ flag: "--where",
44
+ valueName: "<attr>=<value>",
45
+ description:
46
+ "Device-class selector over CDB facts + core fields (repeatable, AND-combined).",
47
+ },
48
+ {
49
+ flag: "--near",
50
+ valueName: "<lat>,<lon>,<radius>",
51
+ description:
52
+ "Geo selector: devices whose GPS is within radius (m/km/mi/ft; bare number = km). Lat-first.",
53
+ },
54
+ {
55
+ flag: "--bbox",
56
+ valueName: "<south>,<west>,<north>,<east>",
57
+ description:
58
+ "Geo selector: devices whose GPS is inside the lat-first bounding box.",
59
+ },
60
+ {
61
+ flag: "--all",
62
+ description: "Fan out across every CDB record (excludes `__default__`).",
63
+ },
64
+ {
65
+ flag: "--default",
66
+ description: "Select the reserved `__default__` record.",
67
+ },
68
+ {
69
+ flag: "--quickchr",
70
+ valueName: "<name>",
71
+ description:
72
+ "Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it).",
73
+ },
74
+ {
75
+ flag: "--concurrency",
76
+ valueName: "<n>",
77
+ description:
78
+ "Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4).",
79
+ },
80
+ ];
81
+
82
+ /** Mutable accumulator a command threads through its parse loop. */
83
+ export interface SelectionFlags {
84
+ groups: string[];
85
+ where: SelectionWhereClause[];
86
+ near?: NearPredicate;
87
+ bbox?: BboxPredicate;
88
+ all: boolean;
89
+ default: boolean;
90
+ /** Repeatable `--quickchr <name>` (named-live-provider; exclusive in v1). */
91
+ quickchr: string[];
92
+ concurrency?: number;
93
+ }
94
+
95
+ export function emptySelectionFlags(): SelectionFlags {
96
+ return { groups: [], where: [], all: false, default: false, quickchr: [] };
97
+ }
98
+
99
+ /** The selection flag tokens, for a command's `--help` table + unknown-flag suggestions. */
100
+ export const selectionFlagTokens = [
101
+ "--group",
102
+ "--where",
103
+ "--near",
104
+ "--bbox",
105
+ "--all",
106
+ "--default",
107
+ "--quickchr",
108
+ "--concurrency",
109
+ ] as const;
110
+
111
+ function parseWhereClause(raw: string): SelectionWhereClause {
112
+ const eq = raw.indexOf("=");
113
+ if (eq <= 0) {
114
+ throw new CentrsError({
115
+ code: "input/invalid-command",
116
+ summary: `--where must be \`<attr>=<value>\`; received: ${raw}`,
117
+ remediation:
118
+ "Pass a device-class selector like `--where board=RB5009` (matches a CDB fact or core field).",
119
+ context: { where: raw },
120
+ });
121
+ }
122
+ return { key: raw.slice(0, eq), value: raw.slice(eq + 1) };
123
+ }
124
+
125
+ /**
126
+ * Try to consume one selection flag at `args[index]`. Returns the new loop index
127
+ * when consumed (the caller sets its index to this and continues), or `null`
128
+ * when the arg is not a selection flag (the caller handles it). Mutates `acc`.
129
+ */
130
+ export function consumeSelectionFlag(
131
+ args: readonly string[],
132
+ index: number,
133
+ acc: SelectionFlags,
134
+ ): number | null {
135
+ const arg = args[index];
136
+ switch (arg) {
137
+ case "--all":
138
+ acc.all = true;
139
+ return index;
140
+ case "--default":
141
+ acc.default = true;
142
+ return index;
143
+ case "--group":
144
+ acc.groups.push(expectValue(args, index + 1, arg));
145
+ return index + 1;
146
+ case "--where":
147
+ acc.where.push(parseWhereClause(expectValue(args, index + 1, arg)));
148
+ return index + 1;
149
+ case "--near":
150
+ acc.near = parseNear(expectValue(args, index + 1, arg));
151
+ return index + 1;
152
+ case "--bbox":
153
+ acc.bbox = parseBbox(expectValue(args, index + 1, arg));
154
+ return index + 1;
155
+ case "--quickchr":
156
+ acc.quickchr.push(expectValue(args, index + 1, arg));
157
+ return index + 1;
158
+ case "--concurrency": {
159
+ const raw = expectValue(args, index + 1, arg);
160
+ const parsed = parseStrictInteger(raw);
161
+ if (parsed === undefined || parsed < 1) {
162
+ throw new CentrsError({
163
+ code: "usage/invalid-concurrency",
164
+ summary: `--concurrency must be an integer >= 1. Received: ${raw}`,
165
+ remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
166
+ context: { concurrency: raw },
167
+ });
168
+ }
169
+ acc.concurrency = parsed;
170
+ return index + 1;
171
+ }
172
+ default:
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** Assemble a {@link TargetSelection} from parsed flags + the command's target positionals. */
178
+ export function buildTargetSelection(
179
+ flags: SelectionFlags,
180
+ positionals: readonly string[],
181
+ ): TargetSelection {
182
+ return {
183
+ positionals,
184
+ groups: flags.groups,
185
+ all: flags.all,
186
+ default: flags.default,
187
+ where: flags.where,
188
+ near: flags.near,
189
+ bbox: flags.bbox,
190
+ quickchr: flags.quickchr,
191
+ };
192
+ }
193
+
194
+ /** True when any CDB selector flag is present (quickchr and positionals excluded). */
195
+ function hasCdbSelector(flags: SelectionFlags): boolean {
196
+ return (
197
+ flags.all ||
198
+ flags.default ||
199
+ flags.groups.length > 0 ||
200
+ flags.where.length > 0 ||
201
+ flags.near !== undefined ||
202
+ flags.bbox !== undefined
203
+ );
204
+ }
205
+
206
+ /**
207
+ * `--quickchr` exclusivity (v1, `docs/CONSTITUTION.md` → Resolution providers):
208
+ * a command targets quickchr machines OR CDB/literal targets, never both.
209
+ * Commands call this after splitting positionals, in both single-target and
210
+ * fan-out mode; relaxing it later is additive.
211
+ */
212
+ export function assertQuickchrExclusive(
213
+ flags: SelectionFlags,
214
+ targetPositionalCount: number,
215
+ ): void {
216
+ if (flags.quickchr.length === 0) {
217
+ return;
218
+ }
219
+ if (hasCdbSelector(flags) || targetPositionalCount > 0) {
220
+ throw new CentrsError({
221
+ code: "usage/conflicting-flags",
222
+ summary:
223
+ "`--quickchr` is exclusive: a command targets quickchr machines or CDB/literal targets, not both.",
224
+ remediation:
225
+ "Drop the positional target / CDB selector (or the `--quickchr` flag) so the selection has one target source.",
226
+ context: {
227
+ quickchr: [...flags.quickchr],
228
+ positionals: targetPositionalCount,
229
+ },
230
+ });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * True when any target-taking flag is present, so positionals are operation
236
+ * args, not targets. Superset of {@link isFanoutMode}'s flag term: a single
237
+ * `--quickchr` names the target (like a lone positional, still single-target
238
+ * mode) but already claims the positional slots.
239
+ */
240
+ export function hasTargetSelector(flags: SelectionFlags): boolean {
241
+ return hasCdbSelector(flags) || flags.quickchr.length > 0;
242
+ }
243
+
244
+ /**
245
+ * Fan-out mode is keyed on INTENT, not resolved count: any selector flag, more
246
+ * than one positional target, or a repeated `--quickchr`. A plain
247
+ * single-positional call — and a single `--quickchr <name>` — stays
248
+ * single-target (never `FanoutData`); a selector that resolves to one/zero
249
+ * members is still fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
250
+ */
251
+ export function isFanoutMode(
252
+ flags: SelectionFlags,
253
+ targetPositionalCount: number,
254
+ ): boolean {
255
+ return (
256
+ hasCdbSelector(flags) ||
257
+ flags.quickchr.length > 1 ||
258
+ targetPositionalCount > 1
259
+ );
260
+ }