@tikoci/centrs 0.1.8 → 0.1.9-next.22
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 +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +33 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/api.ts
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
* structured validation, confirmation, and result-envelope surface.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { assertNotCmrDashboardPath, cmrRunScriptWarnings } from "./core/cmr.ts";
|
|
19
|
+
import {
|
|
20
|
+
type CmrFleetRead,
|
|
21
|
+
cmrFleetCommand,
|
|
22
|
+
cmrFleetGate,
|
|
23
|
+
cmrFleetReadable,
|
|
24
|
+
readCmrFleet,
|
|
25
|
+
} from "./core/cmr-fleet.ts";
|
|
18
26
|
import type {
|
|
19
27
|
CentrsEnvelope,
|
|
20
28
|
CentrsErrorEnvelope,
|
|
@@ -23,11 +31,13 @@ import type {
|
|
|
23
31
|
SettingSource as CoreSettingSource,
|
|
24
32
|
EnvelopeValidationMeta,
|
|
25
33
|
EnvelopeValidationStage,
|
|
34
|
+
StreamLineEnvelope,
|
|
26
35
|
Tip,
|
|
27
36
|
ValidationTrace,
|
|
28
37
|
Warning,
|
|
29
38
|
} from "./core/envelope.ts";
|
|
30
39
|
import { buildTip, notReachedReason } from "./core/envelope.ts";
|
|
40
|
+
import { sentWriteWarnings } from "./core/fanout.ts";
|
|
31
41
|
import {
|
|
32
42
|
inspectArgumentNames,
|
|
33
43
|
inspectChildrenOrEmpty,
|
|
@@ -78,6 +88,7 @@ import {
|
|
|
78
88
|
type ResolvedAuth,
|
|
79
89
|
type ResolvedSetting,
|
|
80
90
|
type ResolvedTarget,
|
|
91
|
+
redactTargetInput,
|
|
81
92
|
resolveAuth,
|
|
82
93
|
resolveBooleanSetting,
|
|
83
94
|
resolveCdb,
|
|
@@ -211,19 +222,24 @@ export type ApiStreamStopReason =
|
|
|
211
222
|
| "transport-error";
|
|
212
223
|
|
|
213
224
|
/**
|
|
214
|
-
* Per-line marker on `--stream` NDJSON output:
|
|
215
|
-
* reply, or the terminating summary.
|
|
216
|
-
* comes first
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
225
|
+
* Per-line marker on `--stream` NDJSON output: the leading `started`, one wire
|
|
226
|
+
* reply, or the terminating summary. `started` (`data: null`, not counted)
|
|
227
|
+
* comes first on every stream that passed its preflight: it carries the full
|
|
228
|
+
* meta and any advice in `tips` — what RouterOS will not report for the
|
|
229
|
+
* request — so even a watch that never gets a reply sees both (#473). A frame
|
|
230
|
+
* carries only its `meta.operation`. Its `reply` is `re` (a row; `data` is the
|
|
231
|
+
* row) or `empty` (RouterOS `!empty`: zero rows; `data` is `null`), and
|
|
232
|
+
* `observedAt` is when centrs received it (host clock, ISO 8601; a slow reader
|
|
233
|
+
* does not move it). `afterStop` marks a reply that arrived after centrs
|
|
234
|
+
* stopped the stream locally, such as the `!empty` a cancelled listen sends;
|
|
235
|
+
* it is not evidence of an empty table.
|
|
221
236
|
*/
|
|
222
237
|
export type ApiStreamMeta =
|
|
223
|
-
| { kind: "
|
|
238
|
+
| { kind: "started" }
|
|
224
239
|
| {
|
|
225
240
|
kind: "frame";
|
|
226
241
|
index: number;
|
|
242
|
+
observedAt: string;
|
|
227
243
|
reply: "re" | "empty";
|
|
228
244
|
afterStop?: true;
|
|
229
245
|
}
|
|
@@ -263,6 +279,15 @@ export interface ApiOperationMeta {
|
|
|
263
279
|
}
|
|
264
280
|
|
|
265
281
|
export type ApiEnvelope = CentrsEnvelope<unknown, ApiOperationMeta>;
|
|
282
|
+
/** A `--stream` frame line: only its own `meta.operation` (#473). */
|
|
283
|
+
export type ApiStreamFrameEnvelope = StreamLineEnvelope<
|
|
284
|
+
unknown,
|
|
285
|
+
Pick<ApiOperationMeta, "kind" | "objectCount"> & {
|
|
286
|
+
stream: Extract<ApiStreamMeta, { kind: "frame" }>;
|
|
287
|
+
}
|
|
288
|
+
>;
|
|
289
|
+
/** One `--stream` line: a full envelope (`started`, `summary`, error) or a frame. */
|
|
290
|
+
export type ApiStreamLine = ApiEnvelope | ApiStreamFrameEnvelope;
|
|
266
291
|
export type ApiSuccessEnvelope = CentrsSuccessEnvelope<
|
|
267
292
|
unknown,
|
|
268
293
|
ApiOperationMeta
|
|
@@ -309,6 +334,8 @@ export interface ResolvedApiRequest {
|
|
|
309
334
|
yes: boolean;
|
|
310
335
|
verbose: boolean;
|
|
311
336
|
warnings: readonly Warning[];
|
|
337
|
+
/** A CMR fleet command's reach, read before the write gate (#453). */
|
|
338
|
+
cmrFleet?: CmrFleetRead;
|
|
312
339
|
}
|
|
313
340
|
|
|
314
341
|
export async function api(
|
|
@@ -340,6 +367,7 @@ export async function apiEnvelope(
|
|
|
340
367
|
const trace: ValidationTrace = {};
|
|
341
368
|
try {
|
|
342
369
|
resolved = await resolveApiRequest(request, env);
|
|
370
|
+
resolved = await withCmrFleetScope(resolved);
|
|
343
371
|
await assertApiWriteConfirmed(request, resolved);
|
|
344
372
|
return await runResolvedApi(resolved, trace);
|
|
345
373
|
} catch (error) {
|
|
@@ -393,6 +421,11 @@ export async function runResolvedApi(
|
|
|
393
421
|
resolved === request
|
|
394
422
|
? protocolRequest
|
|
395
423
|
: buildProtocolApiRequest(resolved),
|
|
424
|
+
{
|
|
425
|
+
onSent: () => {
|
|
426
|
+
trace.dispatched = true;
|
|
427
|
+
},
|
|
428
|
+
},
|
|
396
429
|
);
|
|
397
430
|
const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
|
|
398
431
|
if (routerOsFailure) {
|
|
@@ -440,10 +473,11 @@ function apiRouterOsFailureFromResult(
|
|
|
440
473
|
}
|
|
441
474
|
|
|
442
475
|
/**
|
|
443
|
-
* The `--stream` path: an async generator yielding
|
|
444
|
-
* native reply (`!re` row or `!empty`)
|
|
445
|
-
* terminating summary envelope (`data` =
|
|
446
|
-
* rewrites the command; a `<menu>/listen`
|
|
476
|
+
* The `--stream` path: an async generator yielding a full-meta `started` line,
|
|
477
|
+
* one {@link ApiStreamFrameEnvelope} per native reply (`!re` row or `!empty`)
|
|
478
|
+
* to the literal command, then a terminating summary envelope (`data` =
|
|
479
|
+
* {@link ApiStreamSummary}). It never rewrites the command; a `<menu>/listen`
|
|
480
|
+
* endpoint is how to follow changes.
|
|
447
481
|
* Streaming is native-api only — a `rest-api` (or otherwise unresolved)
|
|
448
482
|
* transport yields a single error envelope. `--count`/`--duration` and
|
|
449
483
|
* `externalSignal` (Ctrl-C) bound it. Errors before the first frame
|
|
@@ -457,7 +491,7 @@ export async function* apiStream(
|
|
|
457
491
|
env: Record<string, string | undefined> = Bun.env,
|
|
458
492
|
externalSignal?: AbortSignal,
|
|
459
493
|
onListening?: () => void,
|
|
460
|
-
): AsyncGenerator<
|
|
494
|
+
): AsyncGenerator<ApiStreamLine, void, void> {
|
|
461
495
|
// `apiStream` is inherently the streaming surface, so force `stream` on the
|
|
462
496
|
// request: otherwise a caller that omits it would resolve `via` to the
|
|
463
497
|
// rest-api default and be rejected. An explicit `--via rest-api` still
|
|
@@ -466,6 +500,7 @@ export async function* apiStream(
|
|
|
466
500
|
let resolved: ResolvedApiRequest | undefined;
|
|
467
501
|
try {
|
|
468
502
|
resolved = await resolveApiRequest(streamRequest, env);
|
|
503
|
+
resolved = await withCmrFleetScope(resolved);
|
|
469
504
|
await assertApiWriteConfirmed(streamRequest, resolved);
|
|
470
505
|
if (resolved.via.value !== "native-api") {
|
|
471
506
|
throw new CentrsError({
|
|
@@ -490,16 +525,18 @@ async function* streamResolvedApi(
|
|
|
490
525
|
request: ResolvedApiRequest,
|
|
491
526
|
externalSignal?: AbortSignal,
|
|
492
527
|
onListening?: () => void,
|
|
493
|
-
): AsyncGenerator<
|
|
528
|
+
): AsyncGenerator<ApiStreamLine, void, void> {
|
|
494
529
|
const backend = adapterForResolved(request);
|
|
495
530
|
|
|
496
531
|
let resolved = request;
|
|
497
532
|
let validation: EnvelopeValidationMeta;
|
|
533
|
+
let validationTips: Tip[] = [];
|
|
498
534
|
const trace: ValidationTrace = {};
|
|
499
535
|
try {
|
|
500
536
|
if (resolved.validate.value) {
|
|
501
537
|
const result = await validateApiRequest(resolved, backend, trace);
|
|
502
538
|
validation = result.validation;
|
|
539
|
+
validationTips = result.tips;
|
|
503
540
|
// The boolean probe below runs after the gate passed (GH#435).
|
|
504
541
|
trace.validation = validation;
|
|
505
542
|
resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
|
|
@@ -512,9 +549,27 @@ async function* streamResolvedApi(
|
|
|
512
549
|
return;
|
|
513
550
|
}
|
|
514
551
|
|
|
515
|
-
// Request-shape advice is known before the first reply: deliver it
|
|
516
|
-
// and keep it on whichever terminal summary follows (#409 review).
|
|
517
|
-
|
|
552
|
+
// Request-shape advice is known before the first reply: deliver it on
|
|
553
|
+
// `started`, and keep it on whichever terminal summary follows (#409 review).
|
|
554
|
+
// The gate's own tips are the same advice the one-shot path returns.
|
|
555
|
+
const tips = [...validationTips, ...streamTips(resolved)];
|
|
556
|
+
// Arrival times in wire order, stamped by the synchronous reply tap: the
|
|
557
|
+
// session queues replies, so a slow reader must not move them. Read by a
|
|
558
|
+
// head index, not `shift()`, which is O(n) per frame on a backlog.
|
|
559
|
+
const arrivals: number[] = [];
|
|
560
|
+
let arrivalsHead = 0;
|
|
561
|
+
const nextArrival = (): number => {
|
|
562
|
+
const at = arrivals[arrivalsHead] ?? Date.now();
|
|
563
|
+
arrivalsHead += 1;
|
|
564
|
+
if (arrivalsHead >= arrivals.length) {
|
|
565
|
+
arrivals.length = 0;
|
|
566
|
+
arrivalsHead = 0;
|
|
567
|
+
} else if (arrivalsHead >= 1024 && arrivalsHead * 2 >= arrivals.length) {
|
|
568
|
+
arrivals.splice(0, arrivalsHead);
|
|
569
|
+
arrivalsHead = 0;
|
|
570
|
+
}
|
|
571
|
+
return at;
|
|
572
|
+
};
|
|
518
573
|
const controller = new AbortController();
|
|
519
574
|
const startedAt = Date.now();
|
|
520
575
|
const counts = { frames: 0, rows: 0, empty: 0 };
|
|
@@ -555,15 +610,21 @@ async function* streamResolvedApi(
|
|
|
555
610
|
}
|
|
556
611
|
|
|
557
612
|
try {
|
|
558
|
-
// A consumer may return immediately after this
|
|
613
|
+
// A consumer may return immediately after this line; keep it inside
|
|
559
614
|
// the session cleanup scope, just like every wire reply.
|
|
560
|
-
|
|
561
|
-
yield streamNoticeEnvelope(resolved, validation, tips);
|
|
562
|
-
}
|
|
615
|
+
yield streamStartedEnvelope(resolved, validation, tips);
|
|
563
616
|
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
564
617
|
for await (const reply of backend.stream(protocolRequest, {
|
|
565
618
|
signal: controller.signal,
|
|
566
|
-
|
|
619
|
+
onReply: (tapped) => {
|
|
620
|
+
if (tapped.type === "re" || tapped.type === "empty") {
|
|
621
|
+
arrivals.push(Date.now());
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
onListening: () => {
|
|
625
|
+
trace.dispatched = true;
|
|
626
|
+
onListening?.();
|
|
627
|
+
},
|
|
567
628
|
onCancelUnacknowledged: () => {
|
|
568
629
|
cancelUnacknowledged = true;
|
|
569
630
|
},
|
|
@@ -575,7 +636,7 @@ async function* streamResolvedApi(
|
|
|
575
636
|
counts.frames += 1;
|
|
576
637
|
if (reply.type === "empty") counts.empty += 1;
|
|
577
638
|
else counts.rows += 1;
|
|
578
|
-
yield streamFrameEnvelope(
|
|
639
|
+
yield streamFrameEnvelope(reply, counts.frames, nextArrival());
|
|
579
640
|
// `--count` counts rows only: an empty tick must never use up the
|
|
580
641
|
// count before the data it is waiting for arrives.
|
|
581
642
|
if (
|
|
@@ -602,7 +663,7 @@ async function* streamResolvedApi(
|
|
|
602
663
|
done,
|
|
603
664
|
);
|
|
604
665
|
} catch (error) {
|
|
605
|
-
const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
|
|
666
|
+
const envelope = buildApiErrorEnvelopeFromResolved(resolved, error, trace);
|
|
606
667
|
envelope.meta.validation = validation;
|
|
607
668
|
envelope.warnings = [...envelope.warnings, ...cancellationWarnings()];
|
|
608
669
|
envelope.tips = [...envelope.tips, ...tips];
|
|
@@ -628,29 +689,39 @@ async function* streamResolvedApi(
|
|
|
628
689
|
}
|
|
629
690
|
|
|
630
691
|
function streamFrameEnvelope(
|
|
631
|
-
resolved: ResolvedApiRequest,
|
|
632
|
-
validation: EnvelopeValidationMeta,
|
|
633
692
|
reply: ProtocolStreamReply,
|
|
634
693
|
index: number,
|
|
635
|
-
|
|
694
|
+
observedAt: number,
|
|
695
|
+
): ApiStreamFrameEnvelope {
|
|
636
696
|
const data = reply.type === "empty" ? null : reply.attributes;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
697
|
+
return {
|
|
698
|
+
ok: true,
|
|
699
|
+
data,
|
|
700
|
+
warnings: [],
|
|
701
|
+
tips: [],
|
|
702
|
+
meta: {
|
|
703
|
+
operation: {
|
|
704
|
+
kind: "api",
|
|
705
|
+
objectCount: countResultObjects(data),
|
|
706
|
+
stream: {
|
|
707
|
+
kind: "frame",
|
|
708
|
+
index,
|
|
709
|
+
observedAt: new Date(observedAt).toISOString(),
|
|
710
|
+
reply: reply.type,
|
|
711
|
+
...(reply.afterStop ? { afterStop: true as const } : {}),
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
},
|
|
643
715
|
};
|
|
644
|
-
return { ok: true, data, warnings: [], tips: [], meta };
|
|
645
716
|
}
|
|
646
717
|
|
|
647
|
-
function
|
|
718
|
+
function streamStartedEnvelope(
|
|
648
719
|
resolved: ResolvedApiRequest,
|
|
649
720
|
validation: EnvelopeValidationMeta,
|
|
650
721
|
tips: Tip[],
|
|
651
722
|
): ApiSuccessEnvelope {
|
|
652
723
|
const meta = metaFromResolved(resolved, validation, null);
|
|
653
|
-
meta.operation.stream = { kind: "
|
|
724
|
+
meta.operation.stream = { kind: "started" };
|
|
654
725
|
return { ok: true, data: null, warnings: [], tips, meta };
|
|
655
726
|
}
|
|
656
727
|
|
|
@@ -1029,9 +1100,10 @@ export async function resolveApiRequest(
|
|
|
1029
1100
|
insecure,
|
|
1030
1101
|
yes: request.yes ?? false,
|
|
1031
1102
|
verbose: request.verbose ?? false,
|
|
1032
|
-
warnings:
|
|
1033
|
-
?
|
|
1034
|
-
|
|
1103
|
+
warnings: [
|
|
1104
|
+
...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
|
|
1105
|
+
...(verb === "run" ? cmrRunScriptWarnings(normalized.path) : []),
|
|
1106
|
+
],
|
|
1035
1107
|
};
|
|
1036
1108
|
}
|
|
1037
1109
|
|
|
@@ -1081,8 +1153,9 @@ export function mapMethodToVerb(method: ApiMethod): ApiVerb {
|
|
|
1081
1153
|
}
|
|
1082
1154
|
|
|
1083
1155
|
/**
|
|
1084
|
-
* Read-only **iff** the method is `GET`,
|
|
1085
|
-
* `print`/`get`/`listen
|
|
1156
|
+
* Read-only **iff** the method is `GET`, the terminal path verb is
|
|
1157
|
+
* `print`/`get`/`listen`, or it is a POST to exactly `/console/inspect`. Everything
|
|
1158
|
+
* else is a write needing confirmation. Keyed
|
|
1086
1159
|
* on the verb/method, not the wire HTTP method — a `POST …/print` paged read does
|
|
1087
1160
|
* not prompt; `--listen` never relaxes the gate (a streaming-but-mutating
|
|
1088
1161
|
* `/system/license/renew` still confirms). See `commands/api/README.md`.
|
|
@@ -1091,6 +1164,13 @@ export function isApiMutating(method: ApiMethod, path: string): boolean {
|
|
|
1091
1164
|
if (method === "GET") {
|
|
1092
1165
|
return false;
|
|
1093
1166
|
}
|
|
1167
|
+
// `/console/inspect` describes the command tree and parses input; it never
|
|
1168
|
+
// runs it, so a POST to it (the only way to pass `request=`) is a read
|
|
1169
|
+
// (#458). POST and the exact path only: PUT/PATCH/DELETE map to
|
|
1170
|
+
// add/set/remove, and an abbreviation stays a write.
|
|
1171
|
+
if (method === "POST" && pathTokens(path).join("/") === "console/inspect") {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1094
1174
|
const terminal = pathTokens(path).at(-1);
|
|
1095
1175
|
if (terminal === "print" || terminal === "get" || terminal === "listen") {
|
|
1096
1176
|
return false;
|
|
@@ -1494,9 +1574,18 @@ async function assertApiWriteConfirmed(
|
|
|
1494
1574
|
if (!isApiMutating(resolved.method, resolved.path) || resolved.yes) {
|
|
1495
1575
|
return;
|
|
1496
1576
|
}
|
|
1577
|
+
const fleet = resolved.cmrFleet
|
|
1578
|
+
? cmrFleetGate(
|
|
1579
|
+
resolved.cmrFleet,
|
|
1580
|
+
resolved.target.identity ??
|
|
1581
|
+
resolved.target.input ??
|
|
1582
|
+
resolved.target.host,
|
|
1583
|
+
)
|
|
1584
|
+
: undefined;
|
|
1497
1585
|
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
1498
1586
|
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
1499
|
-
|
|
1587
|
+
fleet?.prompt ??
|
|
1588
|
+
`Run mutating ${resolved.method} ${resolved.path} over ${resolved.via.value}? Type yes to continue: `,
|
|
1500
1589
|
);
|
|
1501
1590
|
if (confirmed) {
|
|
1502
1591
|
return;
|
|
@@ -1504,7 +1593,9 @@ async function assertApiWriteConfirmed(
|
|
|
1504
1593
|
}
|
|
1505
1594
|
throw new CentrsError({
|
|
1506
1595
|
code: "usage/confirmation-required",
|
|
1507
|
-
summary:
|
|
1596
|
+
summary:
|
|
1597
|
+
fleet?.summary ??
|
|
1598
|
+
`A mutating ${resolved.method} ${resolved.path} request requires explicit confirmation.`,
|
|
1508
1599
|
remediation:
|
|
1509
1600
|
"Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the request.",
|
|
1510
1601
|
context: {
|
|
@@ -1512,10 +1603,61 @@ async function assertApiWriteConfirmed(
|
|
|
1512
1603
|
path: resolved.path,
|
|
1513
1604
|
method: resolved.method,
|
|
1514
1605
|
via: resolved.via.value,
|
|
1606
|
+
...fleet?.context,
|
|
1515
1607
|
},
|
|
1516
1608
|
});
|
|
1517
1609
|
}
|
|
1518
1610
|
|
|
1611
|
+
/**
|
|
1612
|
+
* #453: a mutating CMR fleet request (`POST /cmr/device/reboot`, `PUT
|
|
1613
|
+
* /cmr/vlan`) is one request to the controller that acts on every device it
|
|
1614
|
+
* selects. Read the controller's inventory before the gate so the gate names
|
|
1615
|
+
* that reach, and add the fleet warnings, with or without `--yes`. A `PUT`,
|
|
1616
|
+
* `PATCH` or `DELETE` is the menu's `add`, `set` or `remove`; the `*id` is its
|
|
1617
|
+
* `numbers`.
|
|
1618
|
+
*/
|
|
1619
|
+
async function withCmrFleetScope(
|
|
1620
|
+
resolved: ResolvedApiRequest,
|
|
1621
|
+
): Promise<ResolvedApiRequest> {
|
|
1622
|
+
if (resolved.scriptMode || !isApiMutating(resolved.method, resolved.path)) {
|
|
1623
|
+
return resolved;
|
|
1624
|
+
}
|
|
1625
|
+
const command = cmrFleetCommand(
|
|
1626
|
+
resolved.verb === "run"
|
|
1627
|
+
? resolved.path
|
|
1628
|
+
: `${resolved.path}/${resolved.verb}`,
|
|
1629
|
+
resolved.id === undefined
|
|
1630
|
+
? resolved.body
|
|
1631
|
+
: { ...resolved.body, numbers: resolved.id },
|
|
1632
|
+
);
|
|
1633
|
+
if (!command || !cmrFleetReadable(resolved.via.value)) return resolved;
|
|
1634
|
+
const adapter = createProtocolAdapter({
|
|
1635
|
+
protocol: resolved.via.value,
|
|
1636
|
+
host: resolved.target.host,
|
|
1637
|
+
port: resolved.target.port,
|
|
1638
|
+
tls: resolved.target.tls,
|
|
1639
|
+
baseUrl: resolved.target.baseUrl,
|
|
1640
|
+
username: resolved.auth.username,
|
|
1641
|
+
password: resolved.auth.password,
|
|
1642
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
1643
|
+
insecure: resolved.insecure.value,
|
|
1644
|
+
});
|
|
1645
|
+
try {
|
|
1646
|
+
const cmrFleet = await readCmrFleet(
|
|
1647
|
+
command,
|
|
1648
|
+
adapter,
|
|
1649
|
+
resolved.auth.username,
|
|
1650
|
+
);
|
|
1651
|
+
return {
|
|
1652
|
+
...resolved,
|
|
1653
|
+
cmrFleet,
|
|
1654
|
+
warnings: [...resolved.warnings, ...cmrFleet.warnings],
|
|
1655
|
+
};
|
|
1656
|
+
} finally {
|
|
1657
|
+
await adapter.close();
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1519
1661
|
function assertStreamCapability(resolved: ResolvedApiRequest): void {
|
|
1520
1662
|
if (!resolved.stream) {
|
|
1521
1663
|
return;
|
|
@@ -1534,7 +1676,7 @@ function assertStreamCapability(resolved: ResolvedApiRequest): void {
|
|
|
1534
1676
|
summary:
|
|
1535
1677
|
"`--stream` yields incremental replies instead of a one-shot `api` result.",
|
|
1536
1678
|
remediation:
|
|
1537
|
-
"Consume the stream via `apiStream()` (library) or `centrs api … --stream` (CLI); both yield
|
|
1679
|
+
"Consume the stream via `apiStream()` (library) or `centrs api … --stream` (CLI); both yield a `started` line, a line per reply frame, then a final summary.",
|
|
1538
1680
|
context: { via: resolved.via.value, capability: "stream" },
|
|
1539
1681
|
});
|
|
1540
1682
|
}
|
|
@@ -1678,7 +1820,7 @@ export function buildApiErrorEnvelope(
|
|
|
1678
1820
|
warnings: [],
|
|
1679
1821
|
tips: [],
|
|
1680
1822
|
meta: {
|
|
1681
|
-
target: { input: request.targetInput },
|
|
1823
|
+
target: { input: redactTargetInput(request.targetInput) },
|
|
1682
1824
|
via: requestedVia,
|
|
1683
1825
|
settings: {},
|
|
1684
1826
|
operation: {
|
|
@@ -1718,7 +1860,14 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1718
1860
|
return {
|
|
1719
1861
|
ok: false,
|
|
1720
1862
|
error: serializeCentrsError(centrsError),
|
|
1721
|
-
warnings: [
|
|
1863
|
+
warnings: [
|
|
1864
|
+
...resolved.warnings,
|
|
1865
|
+
...sentWriteWarnings(
|
|
1866
|
+
isApiMutating(resolved.method, resolved.path),
|
|
1867
|
+
centrsError,
|
|
1868
|
+
trace,
|
|
1869
|
+
),
|
|
1870
|
+
],
|
|
1722
1871
|
tips: [],
|
|
1723
1872
|
meta: metaFromResolved(
|
|
1724
1873
|
resolved,
|
|
@@ -1912,7 +2061,7 @@ export function renderApiEnvelope(
|
|
|
1912
2061
|
return toYaml(envelope);
|
|
1913
2062
|
case "text":
|
|
1914
2063
|
return envelope.ok
|
|
1915
|
-
? renderApiSuccessText(envelope
|
|
2064
|
+
? renderApiSuccessText(envelope)
|
|
1916
2065
|
: formatCentrsErrorText(envelope.error, options);
|
|
1917
2066
|
default:
|
|
1918
2067
|
return exhaustiveFormat(format);
|
|
@@ -1924,11 +2073,12 @@ export function renderApiEnvelope(
|
|
|
1924
2073
|
* object per line) for both `json` and `yaml`, and a concise human row for
|
|
1925
2074
|
* `text`. `yaml` deliberately falls back to NDJSON here — YAML's multi-line
|
|
1926
2075
|
* document form is incompatible with line-delimited streaming. Unlike
|
|
1927
|
-
* {@link renderApiEnvelope} this never pretty-prints — every frame
|
|
1928
|
-
* summary are one line each, so a consumer can read the stream line
|
|
2076
|
+
* {@link renderApiEnvelope} this never pretty-prints — `started`, every frame
|
|
2077
|
+
* and the summary are one line each, so a consumer can read the stream line
|
|
2078
|
+
* by line. In text, a `started` line without tips renders as an empty string.
|
|
1929
2079
|
*/
|
|
1930
2080
|
export function renderApiStreamLine(
|
|
1931
|
-
envelope:
|
|
2081
|
+
envelope: ApiStreamLine,
|
|
1932
2082
|
format: ApiOutputFormat,
|
|
1933
2083
|
options: { raw?: boolean; verbose?: boolean } = {},
|
|
1934
2084
|
): string {
|
|
@@ -1951,13 +2101,15 @@ export function renderApiStreamLine(
|
|
|
1951
2101
|
return JSON.stringify(envelope);
|
|
1952
2102
|
}
|
|
1953
2103
|
|
|
1954
|
-
function renderApiStreamFrameText(
|
|
2104
|
+
function renderApiStreamFrameText(
|
|
2105
|
+
envelope: Exclude<ApiStreamLine, ApiErrorEnvelope>,
|
|
2106
|
+
): string {
|
|
1955
2107
|
const stream = envelope.meta.operation?.stream;
|
|
1956
2108
|
if (stream?.kind === "summary") {
|
|
1957
2109
|
const empty = stream.empty > 0 ? `, ${stream.empty} empty` : "";
|
|
1958
2110
|
return `— ${stream.stopReason}: ${stream.rows} row(s)${empty} in ${stream.durationMs}ms`;
|
|
1959
2111
|
}
|
|
1960
|
-
if (stream?.kind === "
|
|
2112
|
+
if (stream?.kind === "started") {
|
|
1961
2113
|
return envelope.tips
|
|
1962
2114
|
.map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
|
|
1963
2115
|
.join("\n");
|
|
@@ -1985,24 +2137,24 @@ function rawErrorPayload(
|
|
|
1985
2137
|
return payload;
|
|
1986
2138
|
}
|
|
1987
2139
|
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2140
|
+
/** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
|
|
2141
|
+
function renderApiSuccessText(envelope: ApiSuccessEnvelope): string {
|
|
2142
|
+
return typeof envelope.data === "string"
|
|
2143
|
+
? envelope.data
|
|
2144
|
+
: JSON.stringify(envelope.data, null, 2);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* The `--verbose` header of a text-mode api result (stderr, #470); none for a
|
|
2149
|
+
* failure before resolution (`via: null`).
|
|
2150
|
+
*/
|
|
2151
|
+
export function apiVerboseLines(envelope: ApiEnvelope): string[] {
|
|
2152
|
+
if (envelope.meta.via === null) return [];
|
|
2153
|
+
const lines = [`via: ${envelope.meta.via}`];
|
|
2154
|
+
if (envelope.meta.validation?.source) {
|
|
2155
|
+
lines.push(`validation: ${envelope.meta.validation.source}`);
|
|
1999
2156
|
}
|
|
2000
|
-
lines
|
|
2001
|
-
typeof envelope.data === "string"
|
|
2002
|
-
? envelope.data
|
|
2003
|
-
: JSON.stringify(envelope.data, null, 2),
|
|
2004
|
-
);
|
|
2005
|
-
return lines.join("\n");
|
|
2157
|
+
return lines;
|
|
2006
2158
|
}
|
|
2007
2159
|
|
|
2008
2160
|
export function validateApiRequestShape(request: ApiRequest): void {
|
|
@@ -2020,6 +2172,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
|
|
|
2020
2172
|
});
|
|
2021
2173
|
}
|
|
2022
2174
|
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
2175
|
+
assertNotCmrDashboardPath(normalized.path);
|
|
2023
2176
|
if (pathTokens(normalized.path).length === 0) {
|
|
2024
2177
|
throw new CentrsError({
|
|
2025
2178
|
code: "input/invalid-path",
|
|
@@ -2096,11 +2249,17 @@ export function apiRequestSummaryFromRequest(
|
|
|
2096
2249
|
validate: summarizedValidate(request),
|
|
2097
2250
|
raw: request.raw ?? false,
|
|
2098
2251
|
format: resolveErrorFormat(request, env),
|
|
2099
|
-
|
|
2100
|
-
|
|
2252
|
+
// Empty lists are omitted, as in the resolved summary, so a fan-out
|
|
2253
|
+
// target's request compares equal to the outer one (#461).
|
|
2254
|
+
query: nonEmpty(summaryQuery(request)),
|
|
2255
|
+
proplist: nonEmpty(buildApiProplist(request)),
|
|
2101
2256
|
};
|
|
2102
2257
|
}
|
|
2103
2258
|
|
|
2259
|
+
function nonEmpty(list: string[] | undefined): string[] | undefined {
|
|
2260
|
+
return list && list.length > 0 ? list : undefined;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2104
2263
|
/** {@link buildApiQuery} for an error summary, which must not throw on the expression it reports. */
|
|
2105
2264
|
function summaryQuery(request: ApiRequest): string[] | undefined {
|
|
2106
2265
|
try {
|
package/src/btest.ts
CHANGED
|
@@ -37,7 +37,12 @@ import {
|
|
|
37
37
|
handleBtestServerConnection,
|
|
38
38
|
runBtestClientSession,
|
|
39
39
|
} from "./protocols/btest-session.ts";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
redactTargetInput,
|
|
42
|
+
resolveAuth,
|
|
43
|
+
resolveCdb,
|
|
44
|
+
resolveTarget,
|
|
45
|
+
} from "./resolver/index.ts";
|
|
41
46
|
|
|
42
47
|
export { BTEST_PORT, BTEST_UDP_PORT_START };
|
|
43
48
|
|
|
@@ -424,7 +429,7 @@ function buildClientErrorEnvelope(
|
|
|
424
429
|
target: {
|
|
425
430
|
...(request.host !== undefined ? { host: request.host } : {}),
|
|
426
431
|
...(request.targetInput !== undefined
|
|
427
|
-
? { input: request.targetInput }
|
|
432
|
+
? { input: redactTargetInput(request.targetInput) }
|
|
428
433
|
: {}),
|
|
429
434
|
port: operation.controlPort,
|
|
430
435
|
},
|