@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.
- package/CHANGELOG.md +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -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 +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -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/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -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/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- 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 +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -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/protocols/mac-telnet-console.ts +61 -17
- 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 +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
package/src/api.ts
CHANGED
|
@@ -45,10 +45,13 @@ import {
|
|
|
45
45
|
type RouterOsProtocol,
|
|
46
46
|
} from "./protocols/index.ts";
|
|
47
47
|
import {
|
|
48
|
+
assertNoQuickchrOverrideConflict,
|
|
48
49
|
type CdbResolution,
|
|
49
50
|
isIpTransport,
|
|
51
|
+
loadEnvFileDefaults,
|
|
50
52
|
parseDuration,
|
|
51
53
|
parseResolvePolicy,
|
|
54
|
+
quickchrConnection,
|
|
52
55
|
type ResolvedAuth,
|
|
53
56
|
type ResolvedSetting,
|
|
54
57
|
type ResolvedTarget,
|
|
@@ -56,6 +59,7 @@ import {
|
|
|
56
59
|
resolveBooleanSetting,
|
|
57
60
|
resolveCdb,
|
|
58
61
|
resolveMacTarget,
|
|
62
|
+
resolveQuickchrTarget,
|
|
59
63
|
resolveStringSetting,
|
|
60
64
|
resolveTarget,
|
|
61
65
|
toCoreSource,
|
|
@@ -71,6 +75,12 @@ export type ApiMethod = (typeof apiMethods)[number];
|
|
|
71
75
|
|
|
72
76
|
export interface ApiRequest {
|
|
73
77
|
targetInput?: string;
|
|
78
|
+
/**
|
|
79
|
+
* quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
|
|
80
|
+
* the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
|
|
81
|
+
* Resolution providers). Conflicts with host/port/username/password.
|
|
82
|
+
*/
|
|
83
|
+
quickchr?: string;
|
|
74
84
|
/** REST-style endpoint path, leniently normalized (`ip/address`, `/rest/ip/address`, `"ip address"`, `ip/address/*1`, `ip/address/listen`). */
|
|
75
85
|
endpoint: string;
|
|
76
86
|
/** HTTP method (`-X`), default `GET`, case-insensitive. */
|
|
@@ -500,8 +510,20 @@ function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
|
500
510
|
export async function resolveApiRequest(
|
|
501
511
|
request: ApiRequest,
|
|
502
512
|
env: Record<string, string | undefined>,
|
|
513
|
+
override?: {
|
|
514
|
+
/**
|
|
515
|
+
* A pre-resolved CDB record, supplied by fan-out so the CDB is loaded ONCE
|
|
516
|
+
* (not per member) and a `--default`/literal member is never re-resolved
|
|
517
|
+
* through `resolveCdb`'s `__default__` synthetic-target fallback.
|
|
518
|
+
*/
|
|
519
|
+
cdbResolution?: CdbResolution;
|
|
520
|
+
/** A pre-loaded `centrs.env` config tier, supplied by fan-out so the file
|
|
521
|
+
* is read ONCE (not per member). */
|
|
522
|
+
config?: Record<string, string | undefined>;
|
|
523
|
+
},
|
|
503
524
|
): Promise<ResolvedApiRequest> {
|
|
504
525
|
validateApiRequestShape(request);
|
|
526
|
+
const config = override?.config ?? (await loadEnvFileDefaults(env));
|
|
505
527
|
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
506
528
|
const method = parseApiMethod(request.method);
|
|
507
529
|
const verb = mapMethodToVerb(method);
|
|
@@ -529,16 +551,29 @@ export async function resolveApiRequest(
|
|
|
529
551
|
const query = buildApiQuery(request);
|
|
530
552
|
const proplist = buildApiProplist(request);
|
|
531
553
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
554
|
+
// A quickchr target is a named-live-provider: host/port/auth come from the
|
|
555
|
+
// live descriptor, bypassing CDB, `__default__`, and MAC resolution.
|
|
556
|
+
let quickchrResolution: Awaited<
|
|
557
|
+
ReturnType<typeof resolveQuickchrTarget>
|
|
558
|
+
> | null = null;
|
|
559
|
+
if (request.quickchr !== undefined) {
|
|
560
|
+
assertNoQuickchrOverrideConflict(request, request.quickchr);
|
|
561
|
+
quickchrResolution = await resolveQuickchrTarget(request.quickchr);
|
|
562
|
+
}
|
|
563
|
+
const cdbResolution = quickchrResolution
|
|
564
|
+
? undefined
|
|
565
|
+
: (override?.cdbResolution ??
|
|
566
|
+
(await resolveCdb(
|
|
567
|
+
{
|
|
568
|
+
targetInput: request.targetInput,
|
|
569
|
+
cdbFile: request.cdbFile,
|
|
570
|
+
cdbPassword: request.cdbPassword,
|
|
571
|
+
},
|
|
572
|
+
env,
|
|
573
|
+
config,
|
|
574
|
+
)));
|
|
575
|
+
const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
|
|
576
|
+
const format = resolveApiFormat(request, env, config);
|
|
542
577
|
// `--raw` forces validation off (constitution: --raw waiver); otherwise the
|
|
543
578
|
// inspect gate is on by default.
|
|
544
579
|
const validate: ResolvedSetting<boolean> = raw
|
|
@@ -550,49 +585,79 @@ export async function resolveApiRequest(
|
|
|
550
585
|
true,
|
|
551
586
|
"validate",
|
|
552
587
|
cdbResolution?.overrides.validate,
|
|
588
|
+
config,
|
|
553
589
|
);
|
|
554
590
|
const timeoutMs = resolveApiTimeout(
|
|
555
591
|
request.timeout,
|
|
556
592
|
env,
|
|
557
593
|
via.value,
|
|
558
594
|
cdbResolution?.overrides.timeoutMs,
|
|
595
|
+
config,
|
|
559
596
|
);
|
|
560
|
-
const macResolution =
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
597
|
+
const macResolution =
|
|
598
|
+
quickchrResolution === null && isIpTransport(via.value)
|
|
599
|
+
? await resolveMacTarget({
|
|
600
|
+
host: request.host,
|
|
601
|
+
targetInput: request.targetInput,
|
|
602
|
+
cdbTarget: cdbResolution?.target,
|
|
603
|
+
env,
|
|
604
|
+
config,
|
|
605
|
+
policy: parseResolvePolicy(
|
|
606
|
+
request.resolve ??
|
|
607
|
+
env["CENTRS_RESOLVE"] ??
|
|
608
|
+
config["CENTRS_RESOLVE"],
|
|
609
|
+
),
|
|
610
|
+
// api has no Layer-2 transport (rest-api/native-api only), so MAC
|
|
611
|
+
// remediation matches retrieve's (no mac-telnet suggestion).
|
|
612
|
+
operation: "retrieve",
|
|
613
|
+
})
|
|
614
|
+
: undefined;
|
|
615
|
+
const connection = quickchrResolution
|
|
616
|
+
? quickchrConnection(quickchrResolution, via.value)
|
|
617
|
+
: null;
|
|
618
|
+
const target = connection
|
|
619
|
+
? connection.target
|
|
620
|
+
: resolveTarget(
|
|
621
|
+
{
|
|
622
|
+
targetInput: request.targetInput,
|
|
623
|
+
host: request.host,
|
|
624
|
+
port: request.port,
|
|
625
|
+
macResolution,
|
|
626
|
+
},
|
|
565
627
|
env,
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
628
|
+
via.value,
|
|
629
|
+
cdbResolution,
|
|
630
|
+
config,
|
|
631
|
+
);
|
|
632
|
+
const auth = connection
|
|
633
|
+
? connection.auth
|
|
634
|
+
: resolveAuth(
|
|
635
|
+
{ username: request.username, password: request.password },
|
|
636
|
+
env,
|
|
637
|
+
cdbResolution,
|
|
638
|
+
config,
|
|
639
|
+
);
|
|
640
|
+
// A quickchr TLS endpoint is trusted by provenance (self-signed VM cert) —
|
|
641
|
+
// `insecure` is forced with provider provenance and the provider-trust
|
|
642
|
+
// warning rides the envelope (see QuickchrConnection.insecure).
|
|
643
|
+
const insecure =
|
|
644
|
+
connection?.insecure === true
|
|
645
|
+
? {
|
|
646
|
+
value: true,
|
|
647
|
+
source: {
|
|
648
|
+
kind: "provider" as const,
|
|
649
|
+
key: `quickchr:${request.quickchr}`,
|
|
650
|
+
},
|
|
651
|
+
}
|
|
652
|
+
: resolveBooleanSetting(
|
|
653
|
+
request.insecure,
|
|
654
|
+
env,
|
|
655
|
+
"CENTRS_INSECURE",
|
|
656
|
+
false,
|
|
657
|
+
"insecure",
|
|
658
|
+
cdbResolution?.overrides.insecure,
|
|
659
|
+
config,
|
|
660
|
+
);
|
|
596
661
|
|
|
597
662
|
return {
|
|
598
663
|
endpoint: request.endpoint,
|
|
@@ -620,7 +685,9 @@ export async function resolveApiRequest(
|
|
|
620
685
|
insecure,
|
|
621
686
|
yes: request.yes ?? false,
|
|
622
687
|
verbose: request.verbose ?? false,
|
|
623
|
-
warnings:
|
|
688
|
+
warnings: connection
|
|
689
|
+
? [...connection.warnings]
|
|
690
|
+
: (cdbResolution?.warnings ?? []),
|
|
624
691
|
};
|
|
625
692
|
}
|
|
626
693
|
|
|
@@ -918,7 +985,7 @@ async function validateApiRequest(
|
|
|
918
985
|
context: {
|
|
919
986
|
path: resolved.path,
|
|
920
987
|
verb: resolved.verb,
|
|
921
|
-
|
|
988
|
+
parameter: missing[0],
|
|
922
989
|
requestedAttributes: requested,
|
|
923
990
|
availableAttributes: available,
|
|
924
991
|
validationSource: "/console/inspect request=child+completion",
|
|
@@ -963,7 +1030,7 @@ async function validateApiRequest(
|
|
|
963
1030
|
context: {
|
|
964
1031
|
path: resolved.path,
|
|
965
1032
|
verb: resolved.verb,
|
|
966
|
-
|
|
1033
|
+
parameter: missing[0],
|
|
967
1034
|
requestedAttributes: requested,
|
|
968
1035
|
availableAttributes: available,
|
|
969
1036
|
validationSource: "/console/inspect request=child+completion",
|
|
@@ -1082,6 +1149,7 @@ function resolveApiProtocol(
|
|
|
1082
1149
|
env: Record<string, string | undefined>,
|
|
1083
1150
|
listen: boolean,
|
|
1084
1151
|
cdb?: CdbResolution,
|
|
1152
|
+
config: Record<string, string | undefined> = {},
|
|
1085
1153
|
): ResolvedSetting<RouterOsProtocol> {
|
|
1086
1154
|
// A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
|
|
1087
1155
|
// REST stays the default for one-shot calls.
|
|
@@ -1094,6 +1162,7 @@ function resolveApiProtocol(
|
|
|
1094
1162
|
"via",
|
|
1095
1163
|
undefined,
|
|
1096
1164
|
cdb?.overrides.via,
|
|
1165
|
+
config,
|
|
1097
1166
|
);
|
|
1098
1167
|
if (!via) {
|
|
1099
1168
|
throw new CentrsError({
|
|
@@ -1102,29 +1171,42 @@ function resolveApiProtocol(
|
|
|
1102
1171
|
remediation: "Report this bug; api should default to rest-api.",
|
|
1103
1172
|
});
|
|
1104
1173
|
}
|
|
1105
|
-
|
|
1174
|
+
assertApiProtocolSupported(via.value);
|
|
1175
|
+
return via as ResolvedSetting<RouterOsProtocol>;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Reject a protocol identifier `api` cannot run over. Throws `settings/invalid-via`
|
|
1180
|
+
* for an unknown protocol and `routeros/protocol-not-implemented` for a known
|
|
1181
|
+
* transport (e.g. ssh / mac-telnet) that `api` does not support. Shared so a
|
|
1182
|
+
* globally-pinned `--via` / `CENTRS_VIA` can be rejected as an orchestration
|
|
1183
|
+
* pre-flight error in fan-out (`api-fanout.ts`) rather than degrading into N
|
|
1184
|
+
* identical per-target resolve failures.
|
|
1185
|
+
*/
|
|
1186
|
+
export function assertApiProtocolSupported(via: string): void {
|
|
1187
|
+
if (!plannedProtocols.includes(via as RouterOsProtocol)) {
|
|
1106
1188
|
throw new CentrsError({
|
|
1107
1189
|
code: "settings/invalid-via",
|
|
1108
|
-
summary: `Unsupported protocol identifier: ${via
|
|
1190
|
+
summary: `Unsupported protocol identifier: ${via}`,
|
|
1109
1191
|
remediation: "`api` runs over `rest-api` (default) or `native-api`.",
|
|
1110
|
-
context: { via
|
|
1192
|
+
context: { via },
|
|
1111
1193
|
});
|
|
1112
1194
|
}
|
|
1113
|
-
if (via
|
|
1195
|
+
if (via !== "rest-api" && via !== "native-api") {
|
|
1114
1196
|
throw new CentrsError({
|
|
1115
1197
|
code: "routeros/protocol-not-implemented",
|
|
1116
|
-
summary: `api over ${via
|
|
1198
|
+
summary: `api over ${via} is not supported.`,
|
|
1117
1199
|
remediation:
|
|
1118
1200
|
"Use `--via rest-api` (default) or `--via native-api`. Console transports (ssh/mac-telnet) run through `execute`/`terminal`.",
|
|
1119
|
-
context: { via
|
|
1201
|
+
context: { via, capability: "execute" },
|
|
1120
1202
|
});
|
|
1121
1203
|
}
|
|
1122
|
-
return via as ResolvedSetting<RouterOsProtocol>;
|
|
1123
1204
|
}
|
|
1124
1205
|
|
|
1125
1206
|
function resolveApiFormat(
|
|
1126
1207
|
request: ApiRequest,
|
|
1127
1208
|
env: Record<string, string | undefined>,
|
|
1209
|
+
config: Record<string, string | undefined> = {},
|
|
1128
1210
|
): ResolvedSetting<ApiOutputFormat> {
|
|
1129
1211
|
return resolveStringSetting(
|
|
1130
1212
|
request.format,
|
|
@@ -1133,6 +1215,8 @@ function resolveApiFormat(
|
|
|
1133
1215
|
"json",
|
|
1134
1216
|
"format",
|
|
1135
1217
|
parseApiOutputFormat,
|
|
1218
|
+
undefined,
|
|
1219
|
+
config,
|
|
1136
1220
|
) as ResolvedSetting<ApiOutputFormat>;
|
|
1137
1221
|
}
|
|
1138
1222
|
|
|
@@ -1141,6 +1225,7 @@ function resolveApiTimeout(
|
|
|
1141
1225
|
env: Record<string, string | undefined>,
|
|
1142
1226
|
via: RouterOsProtocol,
|
|
1143
1227
|
commentKv?: ResolvedSetting<number>,
|
|
1228
|
+
config: Record<string, string | undefined> = {},
|
|
1144
1229
|
): ResolvedSetting<number> {
|
|
1145
1230
|
const resolved = resolveStringSetting(
|
|
1146
1231
|
timeout === undefined ? undefined : String(timeout),
|
|
@@ -1161,6 +1246,7 @@ function resolveApiTimeout(
|
|
|
1161
1246
|
return parsed;
|
|
1162
1247
|
},
|
|
1163
1248
|
commentKv,
|
|
1249
|
+
config,
|
|
1164
1250
|
);
|
|
1165
1251
|
if (!resolved) {
|
|
1166
1252
|
throw new Error("timeout resolution produced no value");
|
|
@@ -1402,7 +1488,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
|
|
|
1402
1488
|
}
|
|
1403
1489
|
}
|
|
1404
1490
|
|
|
1405
|
-
function parseApiMethod(method: string | undefined): ApiMethod {
|
|
1491
|
+
export function parseApiMethod(method: string | undefined): ApiMethod {
|
|
1406
1492
|
if (method === undefined) {
|
|
1407
1493
|
return "GET";
|
|
1408
1494
|
}
|
|
@@ -1418,6 +1504,35 @@ function parseApiMethod(method: string | undefined): ApiMethod {
|
|
|
1418
1504
|
});
|
|
1419
1505
|
}
|
|
1420
1506
|
|
|
1507
|
+
/**
|
|
1508
|
+
* Build the request-level summary (no target) for the fan-out outer meta and
|
|
1509
|
+
* for an empty selection where no per-target inner envelope exists. Mirrors the
|
|
1510
|
+
* summary `buildApiErrorEnvelope` assembles from a bare request.
|
|
1511
|
+
*/
|
|
1512
|
+
export function apiRequestSummaryFromRequest(
|
|
1513
|
+
request: ApiRequest,
|
|
1514
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
1515
|
+
): ApiRequestSummary {
|
|
1516
|
+
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
1517
|
+
const parsed = tryParseApiMethod(request.method);
|
|
1518
|
+
const listen = (request.listen ?? false) || normalized.listen;
|
|
1519
|
+
return {
|
|
1520
|
+
endpoint: request.endpoint,
|
|
1521
|
+
path: normalized.path,
|
|
1522
|
+
id: normalized.id,
|
|
1523
|
+
method: parsed ?? request.method ?? "GET",
|
|
1524
|
+
verb: parsed ? mapMethodToVerb(parsed) : null,
|
|
1525
|
+
write: parsed ? isApiMutating(parsed, normalized.path) : false,
|
|
1526
|
+
listen,
|
|
1527
|
+
yes: request.yes ?? false,
|
|
1528
|
+
validate: request.raw ? false : (request.validate ?? true),
|
|
1529
|
+
raw: request.raw ?? false,
|
|
1530
|
+
format: resolveErrorFormat(request, env),
|
|
1531
|
+
query: buildApiQuery(request),
|
|
1532
|
+
proplist: buildApiProplist(request),
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1421
1536
|
/** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
|
|
1422
1537
|
function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
|
|
1423
1538
|
if (method === undefined) {
|
package/src/cli/api.ts
CHANGED
|
@@ -9,12 +9,19 @@ 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,
|
|
15
21
|
formatCentrsErrorText,
|
|
16
22
|
} from "../errors.ts";
|
|
17
23
|
import { describeCentrs } from "../index.ts";
|
|
24
|
+
import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
|
|
18
25
|
import {
|
|
19
26
|
type CliCommandMetadata,
|
|
20
27
|
expectValue,
|
|
@@ -29,6 +36,15 @@ import {
|
|
|
29
36
|
missingTargetError,
|
|
30
37
|
withTips,
|
|
31
38
|
} from "./missing-target.ts";
|
|
39
|
+
import {
|
|
40
|
+
assertQuickchrExclusive,
|
|
41
|
+
buildTargetSelection,
|
|
42
|
+
consumeSelectionFlag,
|
|
43
|
+
emptySelectionFlags,
|
|
44
|
+
isFanoutMode,
|
|
45
|
+
type SelectionFlags,
|
|
46
|
+
selectionCommandOptions,
|
|
47
|
+
} from "./selection.ts";
|
|
32
48
|
|
|
33
49
|
export const apiCommand: CliCommandMetadata = {
|
|
34
50
|
name: "api",
|
|
@@ -43,7 +59,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
43
59
|
"HTTP method, default GET (case-insensitive). GET→print, PUT→add, PATCH→set, DELETE→remove, POST→run.",
|
|
44
60
|
},
|
|
45
61
|
{
|
|
46
|
-
flag: "-f",
|
|
62
|
+
flag: "-f / --field",
|
|
47
63
|
valueName: "<key=value>",
|
|
48
64
|
description:
|
|
49
65
|
"Body field, repeatable; assembled into the JSON body (verbatim string values).",
|
|
@@ -106,6 +122,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
106
122
|
valueName: "<rest-api|native-api>",
|
|
107
123
|
description: "Pin the transport; no silent downgrade. Default rest-api.",
|
|
108
124
|
},
|
|
125
|
+
...selectionCommandOptions,
|
|
109
126
|
{
|
|
110
127
|
flag: "--host",
|
|
111
128
|
valueName: "<host|url>",
|
|
@@ -153,9 +170,9 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
153
170
|
description: "Per-request timeout (for REST, max 60s).",
|
|
154
171
|
},
|
|
155
172
|
{
|
|
156
|
-
flag: "--validate
|
|
173
|
+
flag: "--validate / --no-validate",
|
|
157
174
|
description:
|
|
158
|
-
"Run `/console/inspect` validation before the request (default true).",
|
|
175
|
+
"Run `/console/inspect` validation before the request (default true; `--validate=false` also accepted).",
|
|
159
176
|
},
|
|
160
177
|
{
|
|
161
178
|
flag: "--format",
|
|
@@ -179,11 +196,16 @@ interface ApiCliArgs extends ApiRequest {
|
|
|
179
196
|
format?: ApiOutputFormat;
|
|
180
197
|
/** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
|
|
181
198
|
inputPath?: string;
|
|
199
|
+
/** Parsed target-selection flags (fan-out grammar). */
|
|
200
|
+
selectionFlags?: SelectionFlags;
|
|
201
|
+
/** Positionals before the endpoint — the fan-out target list. */
|
|
202
|
+
targetPositionals?: string[];
|
|
182
203
|
}
|
|
183
204
|
|
|
184
205
|
export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
185
206
|
const parsed: ApiCliArgs = { endpoint: "" };
|
|
186
207
|
const positional: string[] = [];
|
|
208
|
+
const selectionFlags = emptySelectionFlags();
|
|
187
209
|
|
|
188
210
|
for (let index = 0; index < args.length; index += 1) {
|
|
189
211
|
const arg = args[index];
|
|
@@ -313,17 +335,27 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
|
313
335
|
parsed.format = value as ApiOutputFormat;
|
|
314
336
|
break;
|
|
315
337
|
}
|
|
316
|
-
default:
|
|
338
|
+
default: {
|
|
339
|
+
const consumed = consumeSelectionFlag(args, index, selectionFlags);
|
|
340
|
+
if (consumed !== null) {
|
|
341
|
+
index = consumed;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
317
344
|
if (arg.startsWith("-")) {
|
|
318
345
|
throw unknownFlagError("api", arg, apiCommand.options);
|
|
319
346
|
}
|
|
320
347
|
positional.push(arg);
|
|
321
348
|
break;
|
|
349
|
+
}
|
|
322
350
|
}
|
|
323
351
|
}
|
|
324
352
|
|
|
325
|
-
|
|
326
|
-
|
|
353
|
+
// api/retrieve positional boundary: the FINAL positional is the endpoint; every
|
|
354
|
+
// preceding positional is a fan-out target.
|
|
355
|
+
parsed.endpoint = positional.at(-1) ?? "";
|
|
356
|
+
parsed.targetPositionals = positional.slice(0, -1);
|
|
357
|
+
parsed.selectionFlags = selectionFlags;
|
|
358
|
+
parsed.targetInput = parsed.targetPositionals[0];
|
|
327
359
|
parsed.stdinIsTty = process.stdin.isTTY;
|
|
328
360
|
return parsed;
|
|
329
361
|
}
|
|
@@ -336,14 +368,20 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
336
368
|
console.log(renderCommandHelp(describeCentrs(), apiCommand));
|
|
337
369
|
return 0;
|
|
338
370
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
371
|
+
// Selection-shape usage errors fire before `--input` is read: a bad
|
|
372
|
+
// `--quickchr` combination must not block on stdin (`--input -`) or
|
|
373
|
+
// degrade into per-member fan-out failures. The resolver re-checks the
|
|
374
|
+
// override conflict for library callers.
|
|
375
|
+
const selectionFlags = parsed.selectionFlags ?? emptySelectionFlags();
|
|
376
|
+
const targetPositionals = parsed.targetPositionals ?? [];
|
|
377
|
+
assertQuickchrExclusive(selectionFlags, targetPositionals.length);
|
|
378
|
+
if (selectionFlags.quickchr.length > 0) {
|
|
379
|
+
assertNoQuickchrOverrideConflict(
|
|
380
|
+
parsed,
|
|
381
|
+
selectionFlags.quickchr[0] ?? "",
|
|
382
|
+
);
|
|
346
383
|
}
|
|
384
|
+
|
|
347
385
|
// Resolve `--input` (file / stdin) before handing the body to the orchestrator.
|
|
348
386
|
if (parsed.inputPath !== undefined) {
|
|
349
387
|
const inputPath = parsed.inputPath;
|
|
@@ -364,6 +402,23 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
364
402
|
}
|
|
365
403
|
}
|
|
366
404
|
|
|
405
|
+
// Fan-out mode (selector flag present, or >1 positional target) has its own
|
|
406
|
+
// envelope shape, guards, and granular exit code. It runs first and rejects
|
|
407
|
+
// `--stream`/`--listen` + fan-out itself (single-session is exclusive).
|
|
408
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
409
|
+
return await runApiFanoutCli(
|
|
410
|
+
parsed,
|
|
411
|
+
selectionFlags,
|
|
412
|
+
targetPositionals,
|
|
413
|
+
args,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
// A single `--quickchr <name>` is single-target mode (streaming included):
|
|
417
|
+
// the machine name is the target, resolved from the live descriptor.
|
|
418
|
+
if (selectionFlags.quickchr.length === 1) {
|
|
419
|
+
parsed.quickchr = selectionFlags.quickchr[0];
|
|
420
|
+
}
|
|
421
|
+
|
|
367
422
|
// Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
|
|
368
423
|
// the NDJSON envelope stream instead of a single one-shot envelope.
|
|
369
424
|
const streaming =
|
|
@@ -374,6 +429,15 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
374
429
|
return await runApiListenCli(parsed, args);
|
|
375
430
|
}
|
|
376
431
|
|
|
432
|
+
if (!parsed.targetInput && parsed.quickchr === undefined) {
|
|
433
|
+
throw missingTargetError({
|
|
434
|
+
command: "api",
|
|
435
|
+
summary: "`centrs api` requires a <router> and an <endpoint>.",
|
|
436
|
+
remediation:
|
|
437
|
+
"Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
377
441
|
const envelope = await apiEnvelope(parsed);
|
|
378
442
|
const resolvedFormat =
|
|
379
443
|
(
|
|
@@ -407,6 +471,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
407
471
|
// missing `--input` file) instead of falling back to plain text.
|
|
408
472
|
const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
|
|
409
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).
|
|
410
475
|
renderApiEnvelope(envelope, format, {
|
|
411
476
|
raw: true,
|
|
412
477
|
verbose: parsed.verbose ?? false,
|
|
@@ -418,12 +483,14 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
418
483
|
tips,
|
|
419
484
|
);
|
|
420
485
|
console.error(
|
|
486
|
+
// 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
487
|
renderApiEnvelope(envelope, format, {
|
|
422
488
|
verbose: parsed?.verbose ?? false,
|
|
423
489
|
}),
|
|
424
490
|
);
|
|
425
491
|
} else {
|
|
426
492
|
console.error(
|
|
493
|
+
// 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
494
|
formatCentrsErrorText(
|
|
428
495
|
asCentrsError(error, {
|
|
429
496
|
code: "input/invalid-command",
|
|
@@ -439,6 +506,82 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
439
506
|
}
|
|
440
507
|
}
|
|
441
508
|
|
|
509
|
+
async function runApiFanoutCli(
|
|
510
|
+
parsed: ApiCliArgs,
|
|
511
|
+
selectionFlags: SelectionFlags,
|
|
512
|
+
targetPositionals: readonly string[],
|
|
513
|
+
args: readonly string[],
|
|
514
|
+
): Promise<number> {
|
|
515
|
+
const selection = buildTargetSelection(selectionFlags, targetPositionals);
|
|
516
|
+
const format = inferApiFormat(args, parsed);
|
|
517
|
+
try {
|
|
518
|
+
// `--raw` strips the envelope; incompatible with per-target envelopes.
|
|
519
|
+
if (parsed.raw) {
|
|
520
|
+
throw new CentrsError({
|
|
521
|
+
code: "usage/conflicting-flags",
|
|
522
|
+
summary:
|
|
523
|
+
"`--raw` cannot combine with a multi-target selection; fan-out needs per-target envelopes.",
|
|
524
|
+
remediation:
|
|
525
|
+
"Drop `--raw` to fan out, or target a single router for the bare RouterOS body.",
|
|
526
|
+
context: { flags: ["--raw", "fanout"] },
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// `--listen`/`--stream` is single-session.
|
|
530
|
+
const listenRequested =
|
|
531
|
+
(parsed.listen ?? false) || endpointInfersListen(parsed.endpoint);
|
|
532
|
+
if (listenRequested) {
|
|
533
|
+
throw new CentrsError({
|
|
534
|
+
code: "usage/fanout-not-supported",
|
|
535
|
+
summary:
|
|
536
|
+
"`api --stream`/`--listen` is single-session and cannot fan out across multiple targets.",
|
|
537
|
+
remediation:
|
|
538
|
+
"Follow a stream against a single router (no `--group`/`--where`/`--all`/`--default`/multiple positionals).",
|
|
539
|
+
context: { capability: "listen" },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
const envelope = await apiFanout(
|
|
543
|
+
parsed,
|
|
544
|
+
selection,
|
|
545
|
+
Bun.env,
|
|
546
|
+
{},
|
|
547
|
+
{
|
|
548
|
+
concurrency: selectionFlags.concurrency,
|
|
549
|
+
allowAdhoc: true,
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
const resolvedFormat = envelope.meta.operation?.request.format ?? format;
|
|
553
|
+
console.log(
|
|
554
|
+
renderApiFanoutEnvelope(envelope, resolvedFormat, {
|
|
555
|
+
verbose: parsed.verbose ?? false,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
return fanoutExitCode(envelope);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
const envelope = buildApiFanoutErrorEnvelope(
|
|
561
|
+
parsed,
|
|
562
|
+
selection,
|
|
563
|
+
error,
|
|
564
|
+
Bun.env,
|
|
565
|
+
);
|
|
566
|
+
console.error(
|
|
567
|
+
// 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.
|
|
568
|
+
renderApiFanoutEnvelope(envelope, format, {
|
|
569
|
+
verbose: parsed.verbose ?? false,
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
return 1;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Whether an endpoint's trailing segment infers `--listen` (without throwing). */
|
|
577
|
+
function endpointInfersListen(endpoint: string): boolean {
|
|
578
|
+
try {
|
|
579
|
+
return normalizeApiEndpoint(endpoint).listen;
|
|
580
|
+
} catch {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
442
585
|
/**
|
|
443
586
|
* Consume the open-ended `--stream` follow: print one line per change frame
|
|
444
587
|
* (NDJSON under `json`, human rows under `text`), then the terminating summary.
|