@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/mcp.ts CHANGED
@@ -11,10 +11,12 @@ import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
11
11
  import { describeCentrs } from "../index.ts";
12
12
  import { resolveMcpConfig } from "../mcp/config.ts";
13
13
  import { runMcpStdio } from "../mcp/server.ts";
14
+ import { loadEnvFileDefaults } from "../resolver/index.ts";
14
15
  import {
15
16
  type CliCommandMetadata,
16
17
  expectValue,
17
18
  renderCommandHelp,
19
+ unknownFlagError,
18
20
  } from "./common.ts";
19
21
 
20
22
  export const mcpCommand: CliCommandMetadata = {
@@ -55,6 +57,9 @@ export function parseMcpCliArgs(args: readonly string[]): McpCliArgs {
55
57
  const parsed: McpCliArgs = {};
56
58
  for (let index = 0; index < args.length; index += 1) {
57
59
  const arg = args[index];
60
+ if (arg === undefined) {
61
+ continue;
62
+ }
58
63
  switch (arg) {
59
64
  case "start":
60
65
  // Optional verb; `centrs mcp` and `centrs mcp start` are equivalent.
@@ -73,7 +78,7 @@ export function parseMcpCliArgs(args: readonly string[]): McpCliArgs {
73
78
  parsed.allowAdhocTargets = true;
74
79
  break;
75
80
  default:
76
- throw new Error(`Unknown flag for centrs mcp: ${arg}`);
81
+ throw unknownFlagError("mcp", arg, mcpCommand.options);
77
82
  }
78
83
  }
79
84
  return parsed;
@@ -90,11 +95,13 @@ export async function runMcpCli(args: readonly string[]): Promise<number> {
90
95
  cdbFile: parsed.cdbFile,
91
96
  cdbPassword: parsed.cdbPassword,
92
97
  allowAdhocTargets: parsed.allowAdhocTargets,
98
+ config: await loadEnvFileDefaults(),
93
99
  });
94
100
  await runMcpStdio(config);
95
101
  return 0;
96
102
  } catch (error) {
97
103
  console.error(
104
+ // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
98
105
  formatCentrsErrorText(
99
106
  asCentrsError(error, {
100
107
  code: "internal/mcp-start",
@@ -4,23 +4,29 @@
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";
20
25
  import {
21
26
  type CliCommandMetadata,
22
27
  expectValue,
23
28
  renderCommandHelp,
29
+ unknownFlagError,
24
30
  } from "./common.ts";
25
31
  import {
26
32
  buildTargetSelectionTips,
@@ -30,11 +36,19 @@ import {
30
36
  missingTargetError,
31
37
  withTips,
32
38
  } from "./missing-target.ts";
39
+ import {
40
+ buildTargetSelection,
41
+ consumeSelectionFlag,
42
+ emptySelectionFlags,
43
+ isFanoutMode,
44
+ type SelectionFlags,
45
+ selectionCommandOptions,
46
+ } from "./selection.ts";
33
47
 
34
48
  export const retrieveCommand: CliCommandMetadata = {
35
49
  name: "retrieve",
36
50
  usage:
37
- "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
51
+ "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
38
52
  summary:
39
53
  "Read RouterOS values through the shared core using the selected protocol.",
40
54
  options: [
@@ -44,18 +58,7 @@ export const retrieveCommand: CliCommandMetadata = {
44
58
  description:
45
59
  "Pin the protocol selector. Defaults to `rest-api` for retrieve.",
46
60
  },
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
- },
61
+ ...selectionCommandOptions,
59
62
  {
60
63
  flag: "--host",
61
64
  valueName: "<host|url>",
@@ -145,37 +148,50 @@ export const retrieveCommand: CliCommandMetadata = {
145
148
  ],
146
149
  };
147
150
 
151
+ type RetrieveCliArgs = RetrieveRequest & {
152
+ help?: boolean;
153
+ selectionFlags?: SelectionFlags;
154
+ targetPositionals?: readonly string[];
155
+ };
156
+
148
157
  export async function runRetrieveCli(args: readonly string[]): Promise<number> {
149
- let request: RetrieveRequest | undefined;
158
+ let request: RetrieveCliArgs | undefined;
150
159
 
151
160
  try {
152
161
  request = parseRetrieveCliArgs(args);
153
- if ((request as { help?: boolean }).help) {
162
+ if (request.help) {
154
163
  console.log(renderCommandHelp(describeCentrs(), retrieveCommand));
155
164
  return 0;
156
165
  }
157
166
 
158
- const envelope = request.group
159
- ? await retrieveGroup(request)
160
- : await retrieve(request);
167
+ // Fan-out mode (a selector flag present, or >1 positional target) has its own
168
+ // envelope shape and granular exit code; a plain single-target call keeps the
169
+ // single-target envelope.
170
+ const selectionFlags = request.selectionFlags ?? emptySelectionFlags();
171
+ const targetPositionals = request.targetPositionals ?? [];
172
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
173
+ return await runRetrieveFanoutCli(
174
+ request,
175
+ selectionFlags,
176
+ targetPositionals,
177
+ args,
178
+ );
179
+ }
180
+
181
+ const envelope = await retrieve(request);
161
182
  const resolvedFormat =
162
183
  (
163
184
  envelope.meta.operation as {
164
185
  request?: { format?: RetrieveOutputFormat };
165
186
  }
166
187
  )?.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);
188
+ console.log(
189
+ renderRetrieveEnvelope(
190
+ envelope as Parameters<typeof renderRetrieveEnvelope>[0],
191
+ resolvedFormat,
192
+ { verbose: request.verbose },
193
+ ),
194
+ );
179
195
  return 0;
180
196
  } catch (error) {
181
197
  const format = inferRequestedFormat(args, request);
@@ -186,31 +202,23 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
186
202
  })
187
203
  : [];
188
204
  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
- }
205
+ // When parsing failed before a request existed, build the error envelope
206
+ // from an empty request rather than reconstructing positionals from raw
207
+ // args — a credential value (e.g. the token after `--password` /
208
+ // `--cdb-password`) would otherwise be echoed as `meta.target.input`.
209
+ const envelope = withTips(
210
+ buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
211
+ tips,
212
+ );
213
+ console.error(
214
+ // 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.
215
+ renderRetrieveEnvelope(envelope, format, {
216
+ verbose: request?.verbose ?? false,
217
+ }),
218
+ );
212
219
  } else {
213
220
  console.error(
221
+ // codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
214
222
  formatCentrsErrorText(
215
223
  asCentrsError(error, {
216
224
  code: "input/invalid-command",
@@ -228,12 +236,50 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
228
236
  }
229
237
  }
230
238
 
231
- function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
232
- help?: boolean;
233
- } {
234
- const request: RetrieveRequest & { help?: boolean } = {
239
+ async function runRetrieveFanoutCli(
240
+ request: RetrieveCliArgs,
241
+ selectionFlags: SelectionFlags,
242
+ targetPositionals: readonly string[],
243
+ args: readonly string[],
244
+ ): Promise<number> {
245
+ const selection = buildTargetSelection(selectionFlags, targetPositionals);
246
+ const format = inferRequestedFormat(args, request);
247
+ try {
248
+ const envelope = await retrieveFanout(
249
+ request,
250
+ selection,
251
+ Bun.env,
252
+ {},
253
+ {
254
+ concurrency: selectionFlags.concurrency,
255
+ allowAdhoc: true,
256
+ },
257
+ );
258
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
259
+ console.log(
260
+ renderRetrieveFanoutEnvelope(envelope, resolvedFormat, {
261
+ verbose: request.verbose ?? false,
262
+ }),
263
+ );
264
+ // Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
265
+ return fanoutExitCode(envelope);
266
+ } catch (error) {
267
+ const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
268
+ console.error(
269
+ // 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.
270
+ renderRetrieveFanoutEnvelope(envelope, format, {
271
+ verbose: request.verbose ?? false,
272
+ }),
273
+ );
274
+ return 1;
275
+ }
276
+ }
277
+
278
+ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
279
+ const request: RetrieveCliArgs = {
235
280
  path: "",
236
281
  };
282
+ const selectionFlags = emptySelectionFlags();
237
283
  const positional: string[] = [];
238
284
 
239
285
  for (let index = 0; index < args.length; index += 1) {
@@ -314,15 +360,6 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
314
360
  case "--resolve":
315
361
  request.resolve = expectValue(args, ++index, arg);
316
362
  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
363
  case "--validate":
327
364
  request.validate = true;
328
365
  break;
@@ -332,35 +369,49 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
332
369
  case "--verbose":
333
370
  request.verbose = true;
334
371
  break;
335
- default:
372
+ default: {
373
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
374
+ if (consumed !== null) {
375
+ index = consumed;
376
+ break;
377
+ }
336
378
  if (arg.startsWith("-")) {
337
- throw new Error(`Unknown retrieve flag: ${arg}`);
379
+ throw unknownFlagError("retrieve", arg, retrieveCommand.options);
338
380
  }
339
381
  positional.push(arg);
340
382
  break;
383
+ }
341
384
  }
342
385
  }
343
386
 
387
+ request.selectionFlags = selectionFlags;
388
+
344
389
  if (request.help) {
345
390
  return request;
346
391
  }
347
392
 
348
- if (request.group !== undefined) {
349
- if (positional.length < 1) {
350
- throw new Error(
351
- "`centrs retrieve --group <name>` requires a <routeros-path>.",
352
- );
353
- }
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
- );
393
+ // api/retrieve positional boundary: the FINAL positional is the routeros-path;
394
+ // every preceding positional is a fan-out target.
395
+ const path = positional.at(-1) ?? "";
396
+ const targetPositionals = positional.slice(0, -1);
397
+ request.path = path;
398
+ request.targetPositionals = targetPositionals;
399
+
400
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
401
+ if (positional.length === 0 || path.length === 0) {
402
+ throw new CentrsError({
403
+ code: "input/invalid-command",
404
+ summary:
405
+ "`centrs retrieve` fan-out requires a <routeros-path> after the selectors/targets.",
406
+ remediation:
407
+ "Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --group prod /system/resource`.",
408
+ context: { command: "retrieve", missingPath: true },
409
+ });
358
410
  }
359
- request.path = positional[0] ?? "";
360
411
  return request;
361
412
  }
362
413
 
363
- if (positional.length === 0) {
414
+ if (positional.length === 0 || targetPositionals.length === 0) {
364
415
  throw missingTargetError({
365
416
  command: "retrieve",
366
417
  summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
@@ -368,14 +419,8 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
368
419
  "Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
369
420
  });
370
421
  }
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] ?? "";
422
+ // Single-target: exactly one target positional (more would be fan-out mode).
423
+ request.targetInput = targetPositionals[0];
379
424
  return request;
380
425
  }
381
426
 
@@ -0,0 +1,200 @@
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: "--concurrency",
70
+ valueName: "<n>",
71
+ description:
72
+ "Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4).",
73
+ },
74
+ ];
75
+
76
+ /** Mutable accumulator a command threads through its parse loop. */
77
+ export interface SelectionFlags {
78
+ groups: string[];
79
+ where: SelectionWhereClause[];
80
+ near?: NearPredicate;
81
+ bbox?: BboxPredicate;
82
+ all: boolean;
83
+ default: boolean;
84
+ concurrency?: number;
85
+ }
86
+
87
+ export function emptySelectionFlags(): SelectionFlags {
88
+ return { groups: [], where: [], all: false, default: false };
89
+ }
90
+
91
+ /** The selection flag tokens, for a command's `--help` table + unknown-flag suggestions. */
92
+ export const selectionFlagTokens = [
93
+ "--group",
94
+ "--where",
95
+ "--near",
96
+ "--bbox",
97
+ "--all",
98
+ "--default",
99
+ "--concurrency",
100
+ ] as const;
101
+
102
+ function parseWhereClause(raw: string): SelectionWhereClause {
103
+ const eq = raw.indexOf("=");
104
+ if (eq <= 0) {
105
+ throw new CentrsError({
106
+ code: "input/invalid-command",
107
+ summary: `--where must be \`<attr>=<value>\`; received: ${raw}`,
108
+ remediation:
109
+ "Pass a device-class selector like `--where board=RB5009` (matches a CDB fact or core field).",
110
+ context: { where: raw },
111
+ });
112
+ }
113
+ return { key: raw.slice(0, eq), value: raw.slice(eq + 1) };
114
+ }
115
+
116
+ /**
117
+ * Try to consume one selection flag at `args[index]`. Returns the new loop index
118
+ * when consumed (the caller sets its index to this and continues), or `null`
119
+ * when the arg is not a selection flag (the caller handles it). Mutates `acc`.
120
+ */
121
+ export function consumeSelectionFlag(
122
+ args: readonly string[],
123
+ index: number,
124
+ acc: SelectionFlags,
125
+ ): number | null {
126
+ const arg = args[index];
127
+ switch (arg) {
128
+ case "--all":
129
+ acc.all = true;
130
+ return index;
131
+ case "--default":
132
+ acc.default = true;
133
+ return index;
134
+ case "--group":
135
+ acc.groups.push(expectValue(args, index + 1, arg));
136
+ return index + 1;
137
+ case "--where":
138
+ acc.where.push(parseWhereClause(expectValue(args, index + 1, arg)));
139
+ return index + 1;
140
+ case "--near":
141
+ acc.near = parseNear(expectValue(args, index + 1, arg));
142
+ return index + 1;
143
+ case "--bbox":
144
+ acc.bbox = parseBbox(expectValue(args, index + 1, arg));
145
+ return index + 1;
146
+ case "--concurrency": {
147
+ const raw = expectValue(args, index + 1, arg);
148
+ const parsed = parseStrictInteger(raw);
149
+ if (parsed === undefined || parsed < 1) {
150
+ throw new CentrsError({
151
+ code: "usage/invalid-concurrency",
152
+ summary: `--concurrency must be an integer >= 1. Received: ${raw}`,
153
+ remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
154
+ context: { concurrency: raw },
155
+ });
156
+ }
157
+ acc.concurrency = parsed;
158
+ return index + 1;
159
+ }
160
+ default:
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /** Assemble a {@link TargetSelection} from parsed flags + the command's target positionals. */
166
+ export function buildTargetSelection(
167
+ flags: SelectionFlags,
168
+ positionals: readonly string[],
169
+ ): TargetSelection {
170
+ return {
171
+ positionals,
172
+ groups: flags.groups,
173
+ all: flags.all,
174
+ default: flags.default,
175
+ where: flags.where,
176
+ near: flags.near,
177
+ bbox: flags.bbox,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Fan-out mode is keyed on INTENT, not resolved count: any selector flag, or more
183
+ * than one positional target. A plain single-positional call stays single-target
184
+ * (never `FanoutData`); a selector that resolves to one/zero members is still
185
+ * fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
186
+ */
187
+ export function isFanoutMode(
188
+ flags: SelectionFlags,
189
+ targetPositionalCount: number,
190
+ ): boolean {
191
+ return (
192
+ flags.all ||
193
+ flags.default ||
194
+ flags.groups.length > 0 ||
195
+ flags.where.length > 0 ||
196
+ flags.near !== undefined ||
197
+ flags.bbox !== undefined ||
198
+ targetPositionalCount > 1
199
+ );
200
+ }