@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/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,
@@ -93,8 +95,14 @@ export const btestCommand: CliCommandMetadata = {
93
95
  },
94
96
  { flag: "--random-data", description: "(client) Incompressible payload." },
95
97
  {
96
- flag: "--authenticate[=false]",
97
- description: "(server) Require EC-SRP5 auth. Default true.",
98
+ flag: "--nat-mode",
99
+ description:
100
+ "(client, UDP only) Originate an outbound datagram first so server→client data can traverse NAT. Automatic when the test receives.",
101
+ },
102
+ {
103
+ flag: "--authenticate / --no-authenticate",
104
+ description:
105
+ "(server) Require EC-SRP5 auth. Default true; `--authenticate=false` also accepted.",
98
106
  },
99
107
  {
100
108
  flag: "--bind",
@@ -127,6 +135,16 @@ export const btestCommand: CliCommandMetadata = {
127
135
  valueName: "<port>",
128
136
  description: "Control port. Default 2000.",
129
137
  },
138
+ {
139
+ flag: "--cdb-file",
140
+ valueName: "<path>",
141
+ description: "(client) Read target credentials from a WinBox CDB file.",
142
+ },
143
+ {
144
+ flag: "--cdb-password",
145
+ valueName: "<password>",
146
+ description: "(client) Decrypt an encrypted WinBox CDB file.",
147
+ },
130
148
  {
131
149
  flag: "--format",
132
150
  valueName: `<${btestOutputFormats.join("|")}>`,
@@ -136,6 +154,22 @@ export const btestCommand: CliCommandMetadata = {
136
154
  ],
137
155
  };
138
156
 
157
+ /**
158
+ * btest's single option list documents both subcommands. Scope "did you mean?"
159
+ * hints to the active mode so a client typo never suggests a server-only flag
160
+ * (and vice versa). Shared flags carry no `(client)`/`(server)` marker, so they
161
+ * stay in both; if a marker is ever dropped the flag just widens to both
162
+ * modes — it degrades to the old behavior, never a crash.
163
+ */
164
+ function btestOptionsFor(
165
+ mode: "client" | "server",
166
+ ): readonly CliCommandOption[] {
167
+ const other = mode === "client" ? "(server)" : "(client)";
168
+ return btestCommand.options.filter(
169
+ (option) => !option.description.includes(other),
170
+ );
171
+ }
172
+
139
173
  /** Parse a bandwidth value: a bare integer or a `K`/`M`/`G` suffix (bits/sec). */
140
174
  function parseBandwidth(value: string, flag: string): number {
141
175
  const match = value.trim().match(/^(\d+(?:\.\d+)?)([kKmMgG]?)$/);
@@ -276,7 +310,7 @@ function parseClientArgs(args: readonly string[]): ClientCliArgs {
276
310
  break;
277
311
  default:
278
312
  if (arg.startsWith("-"))
279
- throw new Error(`Unknown btest client flag: ${arg}`);
313
+ throw unknownFlagError("btest", arg, btestOptionsFor("client"));
280
314
  if (request.targetInput !== undefined)
281
315
  throw new Error(`Unexpected extra argument: ${arg}`);
282
316
  request.targetInput = arg;
@@ -341,7 +375,7 @@ function parseServerArgs(args: readonly string[]): ServerCliArgs {
341
375
  break;
342
376
  default:
343
377
  if (arg.startsWith("-"))
344
- throw new Error(`Unknown btest server flag: ${arg}`);
378
+ throw unknownFlagError("btest", arg, btestOptionsFor("server"));
345
379
  throw new Error(
346
380
  `\`centrs btest server\` takes no positional arguments; got: ${arg}.`,
347
381
  );
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[] = [];
@@ -4,20 +4,31 @@
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
  addDevice,
14
+ type BboxPredicate,
10
15
  buildDevicesErrorEnvelope,
11
16
  type CommentKvUpdate,
17
+ canonicalizeGeoKey,
12
18
  type DevicesCommand,
13
19
  type DevicesEnvelope,
14
20
  type DevicesOutputFormat,
21
+ type DevicesWhereClause,
15
22
  describeCentrs,
16
23
  devicesOutputFormats,
17
24
  editInteractiveOnlyError,
18
25
  listDevices,
19
26
  listGroups,
20
27
  loadCdb,
28
+ type NearPredicate,
29
+ parseBbox,
30
+ parseGpsTuple,
31
+ parseNear,
21
32
  recordTypeFromName,
22
33
  removeDevice,
23
34
  renderDevicesEnvelope,
@@ -30,6 +41,7 @@ import {
30
41
  type CliCommandMetadata,
31
42
  expectValue,
32
43
  renderCommandHelp,
44
+ unknownFlagError,
33
45
  } from "./common.ts";
34
46
 
35
47
  export const devicesCommand: CliCommandMetadata = {
@@ -62,6 +74,24 @@ export const devicesCommand: CliCommandMetadata = {
62
74
  description:
63
75
  "`list` filter / `add`,`edit` first-class group field for the target entry.",
64
76
  },
77
+ {
78
+ flag: "--where",
79
+ valueName: "<attr=value>",
80
+ description:
81
+ "`list` only — repeatable device-class filter over CDB facts (AND-combined), e.g. `--where lat=37.7749`.",
82
+ },
83
+ {
84
+ flag: "--near",
85
+ valueName: "<lat>,<lon>,<radius>",
86
+ description:
87
+ "`list` only — GPS filter: devices within radius (m/km/mi/ft; bare number = km). Lat-first.",
88
+ },
89
+ {
90
+ flag: "--bbox",
91
+ valueName: "<south>,<west>,<north>,<east>",
92
+ description:
93
+ "`list` only — GPS filter: devices inside the lat-first bounding box.",
94
+ },
65
95
  {
66
96
  flag: "--members",
67
97
  description: "`groups` only — expand each group's membership.",
@@ -115,6 +145,36 @@ export const devicesCommand: CliCommandMetadata = {
115
145
  valueName: `<${Object.keys(winBoxCdbRecordType).join("|")}>`,
116
146
  description: "`add` only — record type (default `ipAdmin`).",
117
147
  },
148
+ {
149
+ flag: "--lat / --latitude",
150
+ valueName: "<deg>",
151
+ description:
152
+ "`add`,`set` — latitude in decimal degrees (-90..90); a comment-kv fact, paired with --lon.",
153
+ },
154
+ {
155
+ flag: "--lon / --lng / --longitude / --long",
156
+ valueName: "<deg>",
157
+ description:
158
+ "`add`,`set` — longitude in decimal degrees (-180..180); a comment-kv fact, paired with --lat.",
159
+ },
160
+ {
161
+ flag: "--altitude / --alt / --ele / --elevation",
162
+ valueName: "<meters>",
163
+ description:
164
+ "`add`,`set` — altitude in meters (may be negative); a comment-kv fact.",
165
+ },
166
+ {
167
+ flag: "--altitude-type / --alt-type",
168
+ valueName: "<MSL|AGL>",
169
+ description:
170
+ "`add`,`set` — vertical datum for --altitude (default MSL); case-insensitive.",
171
+ },
172
+ {
173
+ flag: "--gps",
174
+ valueName: "<lat>,<lon>[,<altitude>[,<altitude-type>]]",
175
+ description:
176
+ "`add`,`set` — combined lat,lon[,altitude[,altitude-type]] convenience (lat-first; missing altitude-type defaults MSL).",
177
+ },
118
178
  {
119
179
  flag: "--force",
120
180
  description: "`add` only — overwrite the existing (target, user) entry.",
@@ -164,6 +224,7 @@ interface DevicesCliArgs {
164
224
  cdbFile?: string;
165
225
  cdbPassword?: string;
166
226
  group?: string;
227
+ where?: string[];
167
228
  via?: string;
168
229
  members?: boolean;
169
230
  explain?: boolean;
@@ -181,6 +242,13 @@ interface DevicesCliArgs {
181
242
  format?: DevicesOutputFormat;
182
243
  target?: string;
183
244
  kvArgs?: string[];
245
+ lat?: string;
246
+ lon?: string;
247
+ altitude?: string;
248
+ altitudeType?: string;
249
+ gps?: string;
250
+ near?: NearPredicate;
251
+ bbox?: BboxPredicate;
184
252
  }
185
253
 
186
254
  function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
@@ -206,6 +274,18 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
206
274
  case "--group":
207
275
  parsed.group = expectValue(args, ++index, arg);
208
276
  break;
277
+ case "--where":
278
+ parsed.where = [
279
+ ...(parsed.where ?? []),
280
+ expectValue(args, ++index, arg),
281
+ ];
282
+ break;
283
+ case "--near":
284
+ parsed.near = parseNear(expectValue(args, ++index, arg));
285
+ break;
286
+ case "--bbox":
287
+ parsed.bbox = parseBbox(expectValue(args, ++index, arg));
288
+ break;
209
289
  case "--via":
210
290
  parsed.via = expectValue(args, ++index, arg);
211
291
  break;
@@ -242,6 +322,29 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
242
322
  case "--record-type":
243
323
  parsed.recordType = expectValue(args, ++index, arg);
244
324
  break;
325
+ case "--lat":
326
+ case "--latitude":
327
+ parsed.lat = expectValue(args, ++index, arg);
328
+ break;
329
+ case "--lon":
330
+ case "--lng":
331
+ case "--longitude":
332
+ case "--long":
333
+ parsed.lon = expectValue(args, ++index, arg);
334
+ break;
335
+ case "--altitude":
336
+ case "--alt":
337
+ case "--ele":
338
+ case "--elevation":
339
+ parsed.altitude = expectValue(args, ++index, arg);
340
+ break;
341
+ case "--altitude-type":
342
+ case "--alt-type":
343
+ parsed.altitudeType = expectValue(args, ++index, arg);
344
+ break;
345
+ case "--gps":
346
+ parsed.gps = expectValue(args, ++index, arg);
347
+ break;
245
348
  case "--force":
246
349
  parsed.force = true;
247
350
  break;
@@ -263,7 +366,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
263
366
  break;
264
367
  default:
265
368
  if (arg.startsWith("-")) {
266
- throw new Error(`Unknown devices flag: ${arg}`);
369
+ throw unknownFlagError("devices", arg, devicesCommand.options);
267
370
  }
268
371
  positional.push(arg);
269
372
  break;
@@ -298,6 +401,48 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
298
401
  }
299
402
  parsed.subcommand = sub;
300
403
 
404
+ // Reject flags that don't apply to the resolved subcommand instead of
405
+ // silently ignoring the user's input: `--where`/`--near`/`--bbox` select
406
+ // records for `list`; the GPS *setter* flags write via `add`/`set`.
407
+ const selectorFlag =
408
+ (parsed.where?.length ?? 0) > 0
409
+ ? "--where"
410
+ : parsed.near !== undefined
411
+ ? "--near"
412
+ : parsed.bbox !== undefined
413
+ ? "--bbox"
414
+ : undefined;
415
+ if (selectorFlag !== undefined && sub !== "list") {
416
+ throw new CentrsError({
417
+ code: "input/invalid-command",
418
+ summary: `\`${selectorFlag}\` is not supported by \`centrs devices ${sub}\`.`,
419
+ remediation:
420
+ "`--where`/`--near`/`--bbox` select records for `centrs devices list`. For `add`/`set`, name the `<target>` directly.",
421
+ context: { subcommand: sub, flag: selectorFlag },
422
+ });
423
+ }
424
+ const geoFlag =
425
+ parsed.lat !== undefined
426
+ ? "--lat"
427
+ : parsed.lon !== undefined
428
+ ? "--lon"
429
+ : parsed.altitude !== undefined
430
+ ? "--altitude"
431
+ : parsed.altitudeType !== undefined
432
+ ? "--altitude-type"
433
+ : parsed.gps !== undefined
434
+ ? "--gps"
435
+ : undefined;
436
+ if (geoFlag !== undefined && sub !== "add" && sub !== "set") {
437
+ throw new CentrsError({
438
+ code: "input/invalid-command",
439
+ summary: `GPS flag \`${geoFlag}\` is not supported by \`centrs devices ${sub}\`.`,
440
+ remediation:
441
+ "Store GPS with `centrs devices add`/`set` (e.g. `--gps <lat>,<lon>`); query it with `centrs devices list --where lat=…` / `--near`.",
442
+ context: { subcommand: sub, flag: geoFlag },
443
+ });
444
+ }
445
+
301
446
  if (sub === "show" || sub === "edit" || sub === "remove") {
302
447
  if (rest.length !== 1) {
303
448
  throw new Error(
@@ -324,6 +469,32 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
324
469
  return parsed;
325
470
  }
326
471
 
472
+ /**
473
+ * Parse a `--where attr=value` token for `devices list` (constitution: target
474
+ * selection grammar). Mirrors `src/cli/selection.ts` `parseWhereClause`: a
475
+ * structured `input/invalid-command` (not a bare `Error`) so the surfaced error
476
+ * keeps its remediation/context, consistent with every other CLI surface. Geo
477
+ * alias canonicalization (`latitude=`→`lat=`) is NOT done here — it happens once,
478
+ * centrally, in `matchesWhere` (`src/resolver/facts.ts`), so `devices list` and
479
+ * every fan-out command's `--where` behave identically.
480
+ */
481
+ function parseWhereClause(token: string): DevicesWhereClause {
482
+ const eq = token.indexOf("=");
483
+ if (eq <= 0) {
484
+ throw new CentrsError({
485
+ code: "input/invalid-command",
486
+ summary: `--where must be \`<attr>=<value>\`; received: ${token}`,
487
+ remediation:
488
+ "Pass a device-class selector like `--where board=RB5009` (matches a CDB fact or core field).",
489
+ context: { where: token },
490
+ });
491
+ }
492
+ return {
493
+ key: token.slice(0, eq),
494
+ value: token.slice(eq + 1),
495
+ };
496
+ }
497
+
327
498
  function parseKvArg(token: string): CommentKvUpdate {
328
499
  const eq = token.indexOf("=");
329
500
  if (eq <= 0) {
@@ -331,7 +502,51 @@ function parseKvArg(token: string): CommentKvUpdate {
331
502
  `Invalid kv override "${token}"; expected key=value (use key= to clear a value).`,
332
503
  );
333
504
  }
334
- return { key: token.slice(0, eq), value: parseKvValue(token.slice(eq + 1)) };
505
+ // Alias canonicalization happens in ONE place (`canonicalizeGeoKey`) so a
506
+ // bare `lng=…`/`latitude=…`/`elevation=…` positional lands under the same
507
+ // canonical key a `--lon`/`--lat`/`--altitude` flag would use. A no-op for
508
+ // every non-geo key.
509
+ const key = canonicalizeGeoKey(token.slice(0, eq));
510
+ return { key, value: parseKvValue(token.slice(eq + 1)) };
511
+ }
512
+
513
+ /**
514
+ * Lower the per-device GPS flags (`--lat`/`--lon`/`--altitude`/
515
+ * `--altitude-type` and their aliases, plus the combined `--gps`) into
516
+ * comment-kv updates. These are CLI conveniences only — no new first-class CDB
517
+ * tag — so they merge into the same `CommentKvUpdate[]` as bare `k=v`
518
+ * positionals; range/enum/pairing validation happens once, in
519
+ * `validateCommentKvUpdates` (`src/devices.ts`), regardless of which flag
520
+ * produced the update. `--gps` is expanded first so a discrete `--lat`/`--lon`/
521
+ * `--altitude`/`--altitude-type` flag passed alongside it wins (applied later
522
+ * in the array).
523
+ */
524
+ function buildGeoUpdates(parsed: DevicesCliArgs): CommentKvUpdate[] {
525
+ const updates: CommentKvUpdate[] = [];
526
+ if (parsed.gps !== undefined) {
527
+ const gps = parseGpsTuple(parsed.gps);
528
+ updates.push({ key: "lat", value: gps.raw.lat });
529
+ updates.push({ key: "lon", value: gps.raw.lon });
530
+ if (gps.raw.altitude !== undefined) {
531
+ updates.push({ key: "altitude", value: gps.raw.altitude });
532
+ }
533
+ if (gps.raw.altitudeType !== undefined) {
534
+ updates.push({ key: "altitude-type", value: gps.raw.altitudeType });
535
+ }
536
+ }
537
+ if (parsed.lat !== undefined) {
538
+ updates.push({ key: "lat", value: parsed.lat });
539
+ }
540
+ if (parsed.lon !== undefined) {
541
+ updates.push({ key: "lon", value: parsed.lon });
542
+ }
543
+ if (parsed.altitude !== undefined) {
544
+ updates.push({ key: "altitude", value: parsed.altitude });
545
+ }
546
+ if (parsed.altitudeType !== undefined) {
547
+ updates.push({ key: "altitude-type", value: parsed.altitudeType });
548
+ }
549
+ return updates;
335
550
  }
336
551
 
337
552
  function parseKvValue(value: string): string {
@@ -400,7 +615,13 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
400
615
  let envelope: DevicesEnvelope<unknown>;
401
616
  switch (parsed.subcommand) {
402
617
  case "list":
403
- envelope = listDevices({ cdb, group: parsed.group });
618
+ envelope = listDevices({
619
+ cdb,
620
+ group: parsed.group,
621
+ where: (parsed.where ?? []).map(parseWhereClause),
622
+ near: parsed.near,
623
+ bbox: parsed.bbox,
624
+ });
404
625
  break;
405
626
  case "show":
406
627
  if (!parsed.target) {
@@ -441,7 +662,10 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
441
662
  profile: resolveProfile(parsed),
442
663
  session: parsed.session,
443
664
  comment: parsed.comment,
444
- commentKvUpdates: (parsed.kvArgs ?? []).map(parseKvArg),
665
+ commentKvUpdates: [
666
+ ...buildGeoUpdates(parsed),
667
+ ...(parsed.kvArgs ?? []).map(parseKvArg),
668
+ ],
445
669
  force: parsed.force,
446
670
  strict: parsed.strict,
447
671
  });
@@ -459,7 +683,10 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
459
683
  cdb,
460
684
  target: parsed.target,
461
685
  match: parsed.match,
462
- updates: (parsed.kvArgs ?? []).map(parseKvArg),
686
+ updates: [
687
+ ...buildGeoUpdates(parsed),
688
+ ...(parsed.kvArgs ?? []).map(parseKvArg),
689
+ ],
463
690
  user: parsed.user,
464
691
  password: parsed.password,
465
692
  group: parsed.group,
@@ -506,8 +733,10 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
506
733
  centrsError,
507
734
  );
508
735
  if (format === "json" || format === "yaml") {
736
+ // 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.
509
737
  console.error(renderDevicesEnvelope(envelope, format));
510
738
  } else {
739
+ // 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.
511
740
  console.error(formatCentrsErrorText(centrsError));
512
741
  }
513
742
  return 1;
@@ -20,6 +20,7 @@ import {
20
20
  type CliCommandMetadata,
21
21
  expectValue,
22
22
  renderCommandHelp,
23
+ unknownFlagError,
23
24
  } from "./common.ts";
24
25
 
25
26
  export const discoverCommand: CliCommandMetadata = {
@@ -134,7 +135,7 @@ function parseDiscoverCliArgs(args: readonly string[]): DiscoverCliArgs {
134
135
  break;
135
136
  default:
136
137
  if (arg.startsWith("-")) {
137
- throw new Error(`Unknown discover flag: ${arg}`);
138
+ throw unknownFlagError("discover", arg, discoverCommand.options);
138
139
  }
139
140
  throw new Error(
140
141
  `\`centrs discover\` does not accept positional arguments; got: ${arg}.`,
@@ -206,6 +207,7 @@ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
206
207
  remediation:
207
208
  "Use `centrs discover --help` to inspect the supported flags.",
208
209
  });
210
+ // 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.
209
211
  console.error(formatCentrsErrorText(centrsError));
210
212
  return 1;
211
213
  }