@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/api.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import {
|
|
48
48
|
type CdbResolution,
|
|
49
49
|
isIpTransport,
|
|
50
|
+
loadEnvFileDefaults,
|
|
50
51
|
parseDuration,
|
|
51
52
|
parseResolvePolicy,
|
|
52
53
|
type ResolvedAuth,
|
|
@@ -500,8 +501,20 @@ function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
|
500
501
|
export async function resolveApiRequest(
|
|
501
502
|
request: ApiRequest,
|
|
502
503
|
env: Record<string, string | undefined>,
|
|
504
|
+
override?: {
|
|
505
|
+
/**
|
|
506
|
+
* A pre-resolved CDB record, supplied by fan-out so the CDB is loaded ONCE
|
|
507
|
+
* (not per member) and a `--default`/literal member is never re-resolved
|
|
508
|
+
* through `resolveCdb`'s `__default__` synthetic-target fallback.
|
|
509
|
+
*/
|
|
510
|
+
cdbResolution?: CdbResolution;
|
|
511
|
+
/** A pre-loaded `centrs.env` config tier, supplied by fan-out so the file
|
|
512
|
+
* is read ONCE (not per member). */
|
|
513
|
+
config?: Record<string, string | undefined>;
|
|
514
|
+
},
|
|
503
515
|
): Promise<ResolvedApiRequest> {
|
|
504
516
|
validateApiRequestShape(request);
|
|
517
|
+
const config = override?.config ?? (await loadEnvFileDefaults(env));
|
|
505
518
|
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
506
519
|
const method = parseApiMethod(request.method);
|
|
507
520
|
const verb = mapMethodToVerb(method);
|
|
@@ -529,16 +542,19 @@ export async function resolveApiRequest(
|
|
|
529
542
|
const query = buildApiQuery(request);
|
|
530
543
|
const proplist = buildApiProplist(request);
|
|
531
544
|
|
|
532
|
-
const cdbResolution =
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
545
|
+
const cdbResolution =
|
|
546
|
+
override?.cdbResolution ??
|
|
547
|
+
(await resolveCdb(
|
|
548
|
+
{
|
|
549
|
+
targetInput: request.targetInput,
|
|
550
|
+
cdbFile: request.cdbFile,
|
|
551
|
+
cdbPassword: request.cdbPassword,
|
|
552
|
+
},
|
|
553
|
+
env,
|
|
554
|
+
config,
|
|
555
|
+
));
|
|
556
|
+
const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
|
|
557
|
+
const format = resolveApiFormat(request, env, config);
|
|
542
558
|
// `--raw` forces validation off (constitution: --raw waiver); otherwise the
|
|
543
559
|
// inspect gate is on by default.
|
|
544
560
|
const validate: ResolvedSetting<boolean> = raw
|
|
@@ -550,12 +566,14 @@ export async function resolveApiRequest(
|
|
|
550
566
|
true,
|
|
551
567
|
"validate",
|
|
552
568
|
cdbResolution?.overrides.validate,
|
|
569
|
+
config,
|
|
553
570
|
);
|
|
554
571
|
const timeoutMs = resolveApiTimeout(
|
|
555
572
|
request.timeout,
|
|
556
573
|
env,
|
|
557
574
|
via.value,
|
|
558
575
|
cdbResolution?.overrides.timeoutMs,
|
|
576
|
+
config,
|
|
559
577
|
);
|
|
560
578
|
const macResolution = isIpTransport(via.value)
|
|
561
579
|
? await resolveMacTarget({
|
|
@@ -563,7 +581,10 @@ export async function resolveApiRequest(
|
|
|
563
581
|
targetInput: request.targetInput,
|
|
564
582
|
cdbTarget: cdbResolution?.target,
|
|
565
583
|
env,
|
|
566
|
-
|
|
584
|
+
config,
|
|
585
|
+
policy: parseResolvePolicy(
|
|
586
|
+
request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
|
|
587
|
+
),
|
|
567
588
|
// api has no Layer-2 transport (rest-api/native-api only), so MAC
|
|
568
589
|
// remediation matches retrieve's (no mac-telnet suggestion).
|
|
569
590
|
operation: "retrieve",
|
|
@@ -579,11 +600,13 @@ export async function resolveApiRequest(
|
|
|
579
600
|
env,
|
|
580
601
|
via.value,
|
|
581
602
|
cdbResolution,
|
|
603
|
+
config,
|
|
582
604
|
);
|
|
583
605
|
const auth = resolveAuth(
|
|
584
606
|
{ username: request.username, password: request.password },
|
|
585
607
|
env,
|
|
586
608
|
cdbResolution,
|
|
609
|
+
config,
|
|
587
610
|
);
|
|
588
611
|
const insecure = resolveBooleanSetting(
|
|
589
612
|
request.insecure,
|
|
@@ -592,6 +615,7 @@ export async function resolveApiRequest(
|
|
|
592
615
|
false,
|
|
593
616
|
"insecure",
|
|
594
617
|
cdbResolution?.overrides.insecure,
|
|
618
|
+
config,
|
|
595
619
|
);
|
|
596
620
|
|
|
597
621
|
return {
|
|
@@ -1082,6 +1106,7 @@ function resolveApiProtocol(
|
|
|
1082
1106
|
env: Record<string, string | undefined>,
|
|
1083
1107
|
listen: boolean,
|
|
1084
1108
|
cdb?: CdbResolution,
|
|
1109
|
+
config: Record<string, string | undefined> = {},
|
|
1085
1110
|
): ResolvedSetting<RouterOsProtocol> {
|
|
1086
1111
|
// A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
|
|
1087
1112
|
// REST stays the default for one-shot calls.
|
|
@@ -1094,6 +1119,7 @@ function resolveApiProtocol(
|
|
|
1094
1119
|
"via",
|
|
1095
1120
|
undefined,
|
|
1096
1121
|
cdb?.overrides.via,
|
|
1122
|
+
config,
|
|
1097
1123
|
);
|
|
1098
1124
|
if (!via) {
|
|
1099
1125
|
throw new CentrsError({
|
|
@@ -1102,29 +1128,42 @@ function resolveApiProtocol(
|
|
|
1102
1128
|
remediation: "Report this bug; api should default to rest-api.",
|
|
1103
1129
|
});
|
|
1104
1130
|
}
|
|
1105
|
-
|
|
1131
|
+
assertApiProtocolSupported(via.value);
|
|
1132
|
+
return via as ResolvedSetting<RouterOsProtocol>;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Reject a protocol identifier `api` cannot run over. Throws `settings/invalid-via`
|
|
1137
|
+
* for an unknown protocol and `routeros/protocol-not-implemented` for a known
|
|
1138
|
+
* transport (e.g. ssh / mac-telnet) that `api` does not support. Shared so a
|
|
1139
|
+
* globally-pinned `--via` / `CENTRS_VIA` can be rejected as an orchestration
|
|
1140
|
+
* pre-flight error in fan-out (`api-fanout.ts`) rather than degrading into N
|
|
1141
|
+
* identical per-target resolve failures.
|
|
1142
|
+
*/
|
|
1143
|
+
export function assertApiProtocolSupported(via: string): void {
|
|
1144
|
+
if (!plannedProtocols.includes(via as RouterOsProtocol)) {
|
|
1106
1145
|
throw new CentrsError({
|
|
1107
1146
|
code: "settings/invalid-via",
|
|
1108
|
-
summary: `Unsupported protocol identifier: ${via
|
|
1147
|
+
summary: `Unsupported protocol identifier: ${via}`,
|
|
1109
1148
|
remediation: "`api` runs over `rest-api` (default) or `native-api`.",
|
|
1110
|
-
context: { via
|
|
1149
|
+
context: { via },
|
|
1111
1150
|
});
|
|
1112
1151
|
}
|
|
1113
|
-
if (via
|
|
1152
|
+
if (via !== "rest-api" && via !== "native-api") {
|
|
1114
1153
|
throw new CentrsError({
|
|
1115
1154
|
code: "routeros/protocol-not-implemented",
|
|
1116
|
-
summary: `api over ${via
|
|
1155
|
+
summary: `api over ${via} is not supported.`,
|
|
1117
1156
|
remediation:
|
|
1118
1157
|
"Use `--via rest-api` (default) or `--via native-api`. Console transports (ssh/mac-telnet) run through `execute`/`terminal`.",
|
|
1119
|
-
context: { via
|
|
1158
|
+
context: { via, capability: "execute" },
|
|
1120
1159
|
});
|
|
1121
1160
|
}
|
|
1122
|
-
return via as ResolvedSetting<RouterOsProtocol>;
|
|
1123
1161
|
}
|
|
1124
1162
|
|
|
1125
1163
|
function resolveApiFormat(
|
|
1126
1164
|
request: ApiRequest,
|
|
1127
1165
|
env: Record<string, string | undefined>,
|
|
1166
|
+
config: Record<string, string | undefined> = {},
|
|
1128
1167
|
): ResolvedSetting<ApiOutputFormat> {
|
|
1129
1168
|
return resolveStringSetting(
|
|
1130
1169
|
request.format,
|
|
@@ -1133,6 +1172,8 @@ function resolveApiFormat(
|
|
|
1133
1172
|
"json",
|
|
1134
1173
|
"format",
|
|
1135
1174
|
parseApiOutputFormat,
|
|
1175
|
+
undefined,
|
|
1176
|
+
config,
|
|
1136
1177
|
) as ResolvedSetting<ApiOutputFormat>;
|
|
1137
1178
|
}
|
|
1138
1179
|
|
|
@@ -1141,6 +1182,7 @@ function resolveApiTimeout(
|
|
|
1141
1182
|
env: Record<string, string | undefined>,
|
|
1142
1183
|
via: RouterOsProtocol,
|
|
1143
1184
|
commentKv?: ResolvedSetting<number>,
|
|
1185
|
+
config: Record<string, string | undefined> = {},
|
|
1144
1186
|
): ResolvedSetting<number> {
|
|
1145
1187
|
const resolved = resolveStringSetting(
|
|
1146
1188
|
timeout === undefined ? undefined : String(timeout),
|
|
@@ -1161,6 +1203,7 @@ function resolveApiTimeout(
|
|
|
1161
1203
|
return parsed;
|
|
1162
1204
|
},
|
|
1163
1205
|
commentKv,
|
|
1206
|
+
config,
|
|
1164
1207
|
);
|
|
1165
1208
|
if (!resolved) {
|
|
1166
1209
|
throw new Error("timeout resolution produced no value");
|
|
@@ -1402,7 +1445,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
|
|
|
1402
1445
|
}
|
|
1403
1446
|
}
|
|
1404
1447
|
|
|
1405
|
-
function parseApiMethod(method: string | undefined): ApiMethod {
|
|
1448
|
+
export function parseApiMethod(method: string | undefined): ApiMethod {
|
|
1406
1449
|
if (method === undefined) {
|
|
1407
1450
|
return "GET";
|
|
1408
1451
|
}
|
|
@@ -1418,6 +1461,35 @@ function parseApiMethod(method: string | undefined): ApiMethod {
|
|
|
1418
1461
|
});
|
|
1419
1462
|
}
|
|
1420
1463
|
|
|
1464
|
+
/**
|
|
1465
|
+
* Build the request-level summary (no target) for the fan-out outer meta and
|
|
1466
|
+
* for an empty selection where no per-target inner envelope exists. Mirrors the
|
|
1467
|
+
* summary `buildApiErrorEnvelope` assembles from a bare request.
|
|
1468
|
+
*/
|
|
1469
|
+
export function apiRequestSummaryFromRequest(
|
|
1470
|
+
request: ApiRequest,
|
|
1471
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
1472
|
+
): ApiRequestSummary {
|
|
1473
|
+
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
1474
|
+
const parsed = tryParseApiMethod(request.method);
|
|
1475
|
+
const listen = (request.listen ?? false) || normalized.listen;
|
|
1476
|
+
return {
|
|
1477
|
+
endpoint: request.endpoint,
|
|
1478
|
+
path: normalized.path,
|
|
1479
|
+
id: normalized.id,
|
|
1480
|
+
method: parsed ?? request.method ?? "GET",
|
|
1481
|
+
verb: parsed ? mapMethodToVerb(parsed) : null,
|
|
1482
|
+
write: parsed ? isApiMutating(parsed, normalized.path) : false,
|
|
1483
|
+
listen,
|
|
1484
|
+
yes: request.yes ?? false,
|
|
1485
|
+
validate: request.raw ? false : (request.validate ?? true),
|
|
1486
|
+
raw: request.raw ?? false,
|
|
1487
|
+
format: resolveErrorFormat(request, env),
|
|
1488
|
+
query: buildApiQuery(request),
|
|
1489
|
+
proplist: buildApiProplist(request),
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1421
1493
|
/** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
|
|
1422
1494
|
function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
|
|
1423
1495
|
if (method === undefined) {
|
package/src/cli/api.ts
CHANGED
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
renderApiEnvelope,
|
|
10
10
|
renderApiStreamLine,
|
|
11
11
|
} from "../api.ts";
|
|
12
|
+
import {
|
|
13
|
+
apiFanout,
|
|
14
|
+
buildApiFanoutErrorEnvelope,
|
|
15
|
+
renderApiFanoutEnvelope,
|
|
16
|
+
} from "../api-fanout.ts";
|
|
17
|
+
import { fanoutExitCode } from "../core/fanout.ts";
|
|
12
18
|
import {
|
|
13
19
|
asCentrsError,
|
|
14
20
|
CentrsError,
|
|
@@ -29,6 +35,14 @@ import {
|
|
|
29
35
|
missingTargetError,
|
|
30
36
|
withTips,
|
|
31
37
|
} from "./missing-target.ts";
|
|
38
|
+
import {
|
|
39
|
+
buildTargetSelection,
|
|
40
|
+
consumeSelectionFlag,
|
|
41
|
+
emptySelectionFlags,
|
|
42
|
+
isFanoutMode,
|
|
43
|
+
type SelectionFlags,
|
|
44
|
+
selectionCommandOptions,
|
|
45
|
+
} from "./selection.ts";
|
|
32
46
|
|
|
33
47
|
export const apiCommand: CliCommandMetadata = {
|
|
34
48
|
name: "api",
|
|
@@ -106,6 +120,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
106
120
|
valueName: "<rest-api|native-api>",
|
|
107
121
|
description: "Pin the transport; no silent downgrade. Default rest-api.",
|
|
108
122
|
},
|
|
123
|
+
...selectionCommandOptions,
|
|
109
124
|
{
|
|
110
125
|
flag: "--host",
|
|
111
126
|
valueName: "<host|url>",
|
|
@@ -179,11 +194,16 @@ interface ApiCliArgs extends ApiRequest {
|
|
|
179
194
|
format?: ApiOutputFormat;
|
|
180
195
|
/** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
|
|
181
196
|
inputPath?: string;
|
|
197
|
+
/** Parsed target-selection flags (fan-out grammar). */
|
|
198
|
+
selectionFlags?: SelectionFlags;
|
|
199
|
+
/** Positionals before the endpoint — the fan-out target list. */
|
|
200
|
+
targetPositionals?: string[];
|
|
182
201
|
}
|
|
183
202
|
|
|
184
203
|
export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
185
204
|
const parsed: ApiCliArgs = { endpoint: "" };
|
|
186
205
|
const positional: string[] = [];
|
|
206
|
+
const selectionFlags = emptySelectionFlags();
|
|
187
207
|
|
|
188
208
|
for (let index = 0; index < args.length; index += 1) {
|
|
189
209
|
const arg = args[index];
|
|
@@ -313,17 +333,27 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
|
313
333
|
parsed.format = value as ApiOutputFormat;
|
|
314
334
|
break;
|
|
315
335
|
}
|
|
316
|
-
default:
|
|
336
|
+
default: {
|
|
337
|
+
const consumed = consumeSelectionFlag(args, index, selectionFlags);
|
|
338
|
+
if (consumed !== null) {
|
|
339
|
+
index = consumed;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
317
342
|
if (arg.startsWith("-")) {
|
|
318
343
|
throw unknownFlagError("api", arg, apiCommand.options);
|
|
319
344
|
}
|
|
320
345
|
positional.push(arg);
|
|
321
346
|
break;
|
|
347
|
+
}
|
|
322
348
|
}
|
|
323
349
|
}
|
|
324
350
|
|
|
325
|
-
|
|
326
|
-
|
|
351
|
+
// api/retrieve positional boundary: the FINAL positional is the endpoint; every
|
|
352
|
+
// preceding positional is a fan-out target.
|
|
353
|
+
parsed.endpoint = positional.at(-1) ?? "";
|
|
354
|
+
parsed.targetPositionals = positional.slice(0, -1);
|
|
355
|
+
parsed.selectionFlags = selectionFlags;
|
|
356
|
+
parsed.targetInput = parsed.targetPositionals[0];
|
|
327
357
|
parsed.stdinIsTty = process.stdin.isTTY;
|
|
328
358
|
return parsed;
|
|
329
359
|
}
|
|
@@ -336,14 +366,6 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
336
366
|
console.log(renderCommandHelp(describeCentrs(), apiCommand));
|
|
337
367
|
return 0;
|
|
338
368
|
}
|
|
339
|
-
if (!parsed.targetInput) {
|
|
340
|
-
throw missingTargetError({
|
|
341
|
-
command: "api",
|
|
342
|
-
summary: "`centrs api` requires a <router> and an <endpoint>.",
|
|
343
|
-
remediation:
|
|
344
|
-
"Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
369
|
// Resolve `--input` (file / stdin) before handing the body to the orchestrator.
|
|
348
370
|
if (parsed.inputPath !== undefined) {
|
|
349
371
|
const inputPath = parsed.inputPath;
|
|
@@ -364,6 +386,20 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
364
386
|
}
|
|
365
387
|
}
|
|
366
388
|
|
|
389
|
+
// Fan-out mode (selector flag present, or >1 positional target) has its own
|
|
390
|
+
// envelope shape, guards, and granular exit code. It runs first and rejects
|
|
391
|
+
// `--stream`/`--listen` + fan-out itself (single-session is exclusive).
|
|
392
|
+
const selectionFlags = parsed.selectionFlags ?? emptySelectionFlags();
|
|
393
|
+
const targetPositionals = parsed.targetPositionals ?? [];
|
|
394
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
395
|
+
return await runApiFanoutCli(
|
|
396
|
+
parsed,
|
|
397
|
+
selectionFlags,
|
|
398
|
+
targetPositionals,
|
|
399
|
+
args,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
367
403
|
// Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
|
|
368
404
|
// the NDJSON envelope stream instead of a single one-shot envelope.
|
|
369
405
|
const streaming =
|
|
@@ -374,6 +410,15 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
374
410
|
return await runApiListenCli(parsed, args);
|
|
375
411
|
}
|
|
376
412
|
|
|
413
|
+
if (!parsed.targetInput) {
|
|
414
|
+
throw missingTargetError({
|
|
415
|
+
command: "api",
|
|
416
|
+
summary: "`centrs api` requires a <router> and an <endpoint>.",
|
|
417
|
+
remediation:
|
|
418
|
+
"Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
377
422
|
const envelope = await apiEnvelope(parsed);
|
|
378
423
|
const resolvedFormat =
|
|
379
424
|
(
|
|
@@ -407,6 +452,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
407
452
|
// missing `--input` file) instead of falling back to plain text.
|
|
408
453
|
const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
|
|
409
454
|
console.error(
|
|
455
|
+
// codeql[js/clear-text-logging] CLI catch-block error render. No secret logged: envelope stores only passwordProvided (boolean) and target.input (router, not the password).
|
|
410
456
|
renderApiEnvelope(envelope, format, {
|
|
411
457
|
raw: true,
|
|
412
458
|
verbose: parsed.verbose ?? false,
|
|
@@ -418,12 +464,14 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
418
464
|
tips,
|
|
419
465
|
);
|
|
420
466
|
console.error(
|
|
467
|
+
// codeql[js/clear-text-logging] CLI catch-block error render. No secret logged: envelope stores only passwordProvided (boolean) and target.input (router, not the password).
|
|
421
468
|
renderApiEnvelope(envelope, format, {
|
|
422
469
|
verbose: parsed?.verbose ?? false,
|
|
423
470
|
}),
|
|
424
471
|
);
|
|
425
472
|
} else {
|
|
426
473
|
console.error(
|
|
474
|
+
// codeql[js/clear-text-logging] CLI catch-block error render. No secret logged: envelope stores only passwordProvided (boolean) and target.input (router, not the password).
|
|
427
475
|
formatCentrsErrorText(
|
|
428
476
|
asCentrsError(error, {
|
|
429
477
|
code: "input/invalid-command",
|
|
@@ -439,6 +487,82 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
439
487
|
}
|
|
440
488
|
}
|
|
441
489
|
|
|
490
|
+
async function runApiFanoutCli(
|
|
491
|
+
parsed: ApiCliArgs,
|
|
492
|
+
selectionFlags: SelectionFlags,
|
|
493
|
+
targetPositionals: readonly string[],
|
|
494
|
+
args: readonly string[],
|
|
495
|
+
): Promise<number> {
|
|
496
|
+
const selection = buildTargetSelection(selectionFlags, targetPositionals);
|
|
497
|
+
const format = inferApiFormat(args, parsed);
|
|
498
|
+
try {
|
|
499
|
+
// `--raw` strips the envelope; incompatible with per-target envelopes.
|
|
500
|
+
if (parsed.raw) {
|
|
501
|
+
throw new CentrsError({
|
|
502
|
+
code: "usage/conflicting-flags",
|
|
503
|
+
summary:
|
|
504
|
+
"`--raw` cannot combine with a multi-target selection; fan-out needs per-target envelopes.",
|
|
505
|
+
remediation:
|
|
506
|
+
"Drop `--raw` to fan out, or target a single router for the bare RouterOS body.",
|
|
507
|
+
context: { flags: ["--raw", "fanout"] },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// `--listen`/`--stream` is single-session.
|
|
511
|
+
const listenRequested =
|
|
512
|
+
(parsed.listen ?? false) || endpointInfersListen(parsed.endpoint);
|
|
513
|
+
if (listenRequested) {
|
|
514
|
+
throw new CentrsError({
|
|
515
|
+
code: "usage/fanout-not-supported",
|
|
516
|
+
summary:
|
|
517
|
+
"`api --stream`/`--listen` is single-session and cannot fan out across multiple targets.",
|
|
518
|
+
remediation:
|
|
519
|
+
"Follow a stream against a single router (no `--group`/`--where`/`--all`/`--default`/multiple positionals).",
|
|
520
|
+
context: { capability: "listen" },
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const envelope = await apiFanout(
|
|
524
|
+
parsed,
|
|
525
|
+
selection,
|
|
526
|
+
Bun.env,
|
|
527
|
+
{},
|
|
528
|
+
{
|
|
529
|
+
concurrency: selectionFlags.concurrency,
|
|
530
|
+
allowAdhoc: true,
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
const resolvedFormat = envelope.meta.operation?.request.format ?? format;
|
|
534
|
+
console.log(
|
|
535
|
+
renderApiFanoutEnvelope(envelope, resolvedFormat, {
|
|
536
|
+
verbose: parsed.verbose ?? false,
|
|
537
|
+
}),
|
|
538
|
+
);
|
|
539
|
+
return fanoutExitCode(envelope);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
const envelope = buildApiFanoutErrorEnvelope(
|
|
542
|
+
parsed,
|
|
543
|
+
selection,
|
|
544
|
+
error,
|
|
545
|
+
Bun.env,
|
|
546
|
+
);
|
|
547
|
+
console.error(
|
|
548
|
+
// codeql[js/clear-text-logging] Fan-out CLI catch-block error render — twin of #80/#81. No secret logged: the error envelope's request summary, selection and target carry no auth fields; serializeCentrsError holds only code/summary/remediation/context.
|
|
549
|
+
renderApiFanoutEnvelope(envelope, format, {
|
|
550
|
+
verbose: parsed.verbose ?? false,
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
return 1;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Whether an endpoint's trailing segment infers `--listen` (without throwing). */
|
|
558
|
+
function endpointInfersListen(endpoint: string): boolean {
|
|
559
|
+
try {
|
|
560
|
+
return normalizeApiEndpoint(endpoint).listen;
|
|
561
|
+
} catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
442
566
|
/**
|
|
443
567
|
* Consume the open-ended `--stream` follow: print one line per change frame
|
|
444
568
|
* (NDJSON under `json`, human rows under `text`), then the terminating summary.
|
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,
|
|
@@ -136,6 +138,22 @@ export const btestCommand: CliCommandMetadata = {
|
|
|
136
138
|
],
|
|
137
139
|
};
|
|
138
140
|
|
|
141
|
+
/**
|
|
142
|
+
* btest's single option list documents both subcommands. Scope "did you mean?"
|
|
143
|
+
* hints to the active mode so a client typo never suggests a server-only flag
|
|
144
|
+
* (and vice versa). Shared flags carry no `(client)`/`(server)` marker, so they
|
|
145
|
+
* stay in both; if a marker is ever dropped the flag just widens to both
|
|
146
|
+
* modes — it degrades to the old behavior, never a crash.
|
|
147
|
+
*/
|
|
148
|
+
function btestOptionsFor(
|
|
149
|
+
mode: "client" | "server",
|
|
150
|
+
): readonly CliCommandOption[] {
|
|
151
|
+
const other = mode === "client" ? "(server)" : "(client)";
|
|
152
|
+
return btestCommand.options.filter(
|
|
153
|
+
(option) => !option.description.includes(other),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
139
157
|
/** Parse a bandwidth value: a bare integer or a `K`/`M`/`G` suffix (bits/sec). */
|
|
140
158
|
function parseBandwidth(value: string, flag: string): number {
|
|
141
159
|
const match = value.trim().match(/^(\d+(?:\.\d+)?)([kKmMgG]?)$/);
|
|
@@ -276,7 +294,7 @@ function parseClientArgs(args: readonly string[]): ClientCliArgs {
|
|
|
276
294
|
break;
|
|
277
295
|
default:
|
|
278
296
|
if (arg.startsWith("-"))
|
|
279
|
-
throw
|
|
297
|
+
throw unknownFlagError("btest", arg, btestOptionsFor("client"));
|
|
280
298
|
if (request.targetInput !== undefined)
|
|
281
299
|
throw new Error(`Unexpected extra argument: ${arg}`);
|
|
282
300
|
request.targetInput = arg;
|
|
@@ -341,7 +359,7 @@ function parseServerArgs(args: readonly string[]): ServerCliArgs {
|
|
|
341
359
|
break;
|
|
342
360
|
default:
|
|
343
361
|
if (arg.startsWith("-"))
|
|
344
|
-
throw
|
|
362
|
+
throw unknownFlagError("btest", arg, btestOptionsFor("server"));
|
|
345
363
|
throw new Error(
|
|
346
364
|
`\`centrs btest server\` takes no positional arguments; got: ${arg}.`,
|
|
347
365
|
);
|
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[] = [];
|