@tikoci/centrs 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { fanoutExitCode } from "../core/fanout.ts";
1
2
  import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
2
3
  import {
3
4
  buildExecuteErrorEnvelope,
@@ -7,11 +8,17 @@ import {
7
8
  executeOutputFormats,
8
9
  renderExecuteEnvelope,
9
10
  } from "../execute.ts";
11
+ import {
12
+ buildExecuteFanoutErrorEnvelope,
13
+ executeFanout,
14
+ renderExecuteFanoutEnvelope,
15
+ } from "../execute-fanout.ts";
10
16
  import { describeCentrs } from "../index.ts";
11
17
  import {
12
18
  type CliCommandMetadata,
13
19
  expectValue,
14
20
  renderCommandHelp,
21
+ unknownFlagError,
15
22
  } from "./common.ts";
16
23
  import {
17
24
  buildTargetSelectionTips,
@@ -21,10 +28,19 @@ import {
21
28
  missingTargetError,
22
29
  withTips,
23
30
  } from "./missing-target.ts";
31
+ import {
32
+ buildTargetSelection,
33
+ consumeSelectionFlag,
34
+ emptySelectionFlags,
35
+ isFanoutMode,
36
+ type SelectionFlags,
37
+ selectionCommandOptions,
38
+ } from "./selection.ts";
24
39
 
25
40
  export const executeCommand: CliCommandMetadata = {
26
41
  name: "execute",
27
- usage: "centrs execute <target> <command> [flags]",
42
+ usage:
43
+ "centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --group <name> <command> [flags]",
28
44
  summary:
29
45
  "Run a RouterOS read or write command via native API, REST, or mac-telnet.",
30
46
  options: [
@@ -34,6 +50,7 @@ export const executeCommand: CliCommandMetadata = {
34
50
  description:
35
51
  "Pin the protocol selector; no silent downgrade when set. A bare MAC target defaults to mac-telnet.",
36
52
  },
53
+ ...selectionCommandOptions,
37
54
  {
38
55
  flag: "--host",
39
56
  valueName: "<host|url>",
@@ -127,11 +144,15 @@ export const executeCommand: CliCommandMetadata = {
127
144
  interface ExecuteCliArgs extends ExecuteRequest {
128
145
  help?: boolean;
129
146
  format?: ExecuteOutputFormat;
147
+ selectionFlags?: SelectionFlags;
148
+ targetPositionals?: readonly string[];
130
149
  }
131
150
 
132
151
  export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
133
152
  const parsed: ExecuteCliArgs = { command: "" };
153
+ const selectionFlags = emptySelectionFlags();
134
154
  const positional: string[] = [];
155
+ const afterDashDash: string[] = [];
135
156
  let endOfOptions = false;
136
157
 
137
158
  for (let index = 0; index < args.length; index += 1) {
@@ -144,7 +165,7 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
144
165
  // tokens like `--` / `-foo` (or a value that looks like a flag) without the
145
166
  // parser claiming them. `--` itself is consumed, not added to the command.
146
167
  if (endOfOptions) {
147
- positional.push(arg);
168
+ afterDashDash.push(arg);
148
169
  continue;
149
170
  }
150
171
  if (arg === "--") {
@@ -226,17 +247,42 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
226
247
  parsed.format = value as ExecuteOutputFormat;
227
248
  break;
228
249
  }
229
- default:
250
+ default: {
251
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
252
+ if (consumed !== null) {
253
+ index = consumed;
254
+ break;
255
+ }
230
256
  if (arg.startsWith("-")) {
231
- throw new Error(`Unknown execute flag: ${arg}`);
257
+ throw unknownFlagError("execute", arg, executeCommand.options);
232
258
  }
233
259
  positional.push(arg);
234
260
  break;
261
+ }
235
262
  }
236
263
  }
237
264
 
238
- parsed.targetInput = positional[0];
239
- parsed.command = positional.slice(1).join(" ");
265
+ parsed.selectionFlags = selectionFlags;
266
+ // Positional boundary for execute (see `docs/CONSTITUTION.md` → Target selection):
267
+ // - `--` present: targets are the positionals BEFORE it, command is AFTER it.
268
+ // - selector flag present, no `--`: every positional is the command (targets
269
+ // come from the selector).
270
+ // - otherwise (no `--`, no selector): legacy single-target split — first
271
+ // positional is the target, the rest is the command. Multiple positional
272
+ // targets therefore REQUIRE `--`.
273
+ const hasSelector = isFanoutMode(selectionFlags, 0);
274
+ if (endOfOptions) {
275
+ parsed.targetPositionals = positional;
276
+ parsed.command = afterDashDash.join(" ");
277
+ } else if (hasSelector) {
278
+ parsed.targetPositionals = [];
279
+ parsed.command = positional.join(" ");
280
+ } else {
281
+ parsed.targetPositionals =
282
+ positional[0] !== undefined ? [positional[0]] : [];
283
+ parsed.command = positional.slice(1).join(" ");
284
+ }
285
+ parsed.targetInput = parsed.targetPositionals[0];
240
286
  parsed.stdinIsTty = process.stdin.isTTY;
241
287
  return parsed;
242
288
  }
@@ -249,6 +295,20 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
249
295
  console.log(renderCommandHelp(describeCentrs(), executeCommand));
250
296
  return 0;
251
297
  }
298
+
299
+ // Fan-out mode (a selector flag, or >1 positional target before `--`) has its
300
+ // own envelope shape, single up-front write-confirm, and granular exit code.
301
+ const selectionFlags = parsed.selectionFlags ?? emptySelectionFlags();
302
+ const targetPositionals = parsed.targetPositionals ?? [];
303
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
304
+ return await runExecuteFanoutCli(
305
+ parsed,
306
+ selectionFlags,
307
+ targetPositionals,
308
+ args,
309
+ );
310
+ }
311
+
252
312
  // No positional at all: lead with the missing-target guidance (CDB picker /
253
313
  // discover) rather than the missing-command error — there is nothing to run
254
314
  // without a device. A lone positional is treated as the target, so the
@@ -296,12 +356,14 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
296
356
  tips,
297
357
  );
298
358
  console.error(
359
+ // codeql[js/clear-text-logging] No secret reaches this sink. The tainted 'password' flows in only as provenance (SettingSource={kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
299
360
  renderExecuteEnvelope(envelope, format, {
300
361
  verbose: parsed?.verbose ?? false,
301
362
  }),
302
363
  );
303
364
  } else {
304
365
  console.error(
366
+ // codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
305
367
  formatCentrsErrorText(
306
368
  asCentrsError(error, {
307
369
  code: "input/invalid-command",
@@ -317,6 +379,45 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
317
379
  }
318
380
  }
319
381
 
382
+ async function runExecuteFanoutCli(
383
+ parsed: ExecuteCliArgs,
384
+ selectionFlags: SelectionFlags,
385
+ targetPositionals: readonly string[],
386
+ args: readonly string[],
387
+ ): Promise<number> {
388
+ const selection = buildTargetSelection(selectionFlags, targetPositionals);
389
+ const format = inferExecuteFormat(args, parsed);
390
+ try {
391
+ const envelope = await executeFanout(
392
+ parsed,
393
+ selection,
394
+ Bun.env,
395
+ {},
396
+ {
397
+ concurrency: selectionFlags.concurrency,
398
+ allowAdhoc: true,
399
+ },
400
+ );
401
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
402
+ console.log(
403
+ renderExecuteFanoutEnvelope(envelope, resolvedFormat, {
404
+ verbose: parsed.verbose ?? false,
405
+ }),
406
+ );
407
+ // Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
408
+ return fanoutExitCode(envelope);
409
+ } catch (error) {
410
+ const envelope = buildExecuteFanoutErrorEnvelope(parsed, error);
411
+ console.error(
412
+ // codeql[js/clear-text-logging] Twin of #80/#81/#83/#84. No secret logged: buildExecuteFanoutErrorEnvelope meta is {target:{},via,settings:{}}; CodeQL taints the whole request object but the builder projects the password away.
413
+ renderExecuteFanoutEnvelope(envelope, format, {
414
+ verbose: parsed.verbose ?? false,
415
+ }),
416
+ );
417
+ return 1;
418
+ }
419
+ }
420
+
320
421
  function inferExecuteFormat(
321
422
  args: readonly string[],
322
423
  parsed?: ExecuteCliArgs,
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",