@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.
- package/CHANGELOG.md +59 -0
- package/GLOSSARY.txt +71 -0
- package/README.md +24 -8
- package/SECURITY.md +3 -3
- package/commands/api/README.md +27 -9
- package/commands/api/examples.md +109 -6
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +39 -4
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +36 -50
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +36 -11
- package/docs/MATRIX.md +68 -172
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/package.json +6 -6
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +91 -19
- package/src/cli/api.ts +135 -11
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +7 -0
- package/src/core/error-catalog.ts +55 -0
- package/src/core/fanout.ts +143 -2
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +120 -23
- package/src/index.ts +105 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +225 -133
- package/src/retrieve.ts +39 -7
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
package/src/cli/devices.ts
CHANGED
|
@@ -4,20 +4,31 @@
|
|
|
4
4
|
* unchanged from the former monolithic `cli.ts`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
|
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
|
-
|
|
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({
|
|
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:
|
|
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:
|
|
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;
|
package/src/cli/discover.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/cli/execute.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
239
|
-
|
|
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
|
|
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",
|