@tikoci/centrs 0.1.9-next.0 → 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 +177 -40
- package/GLOSSARY.txt +8 -0
- package/README.md +12 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +39 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +108 -14
- package/commands/execute/examples.md +118 -3
- 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 +30 -13
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +8 -8
- package/docs/CONSTITUTION.md +25 -2
- package/docs/MATRIX.md +5 -4
- 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/empty-selection.md +17 -3
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +11 -1
- package/src/api.ts +205 -65
- 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 +44 -20
- 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 +78 -0
- package/src/core/envelope.ts +32 -3
- package/src/core/error-catalog.ts +25 -3
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +130 -6
- package/src/errors.ts +2 -1
- package/src/execute-cmr.ts +95 -78
- package/src/execute-fanout.ts +11 -1
- package/src/execute.ts +105 -23
- package/src/index.ts +18 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- 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
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
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";
|
|
19
26
|
import type {
|
|
20
27
|
CentrsEnvelope,
|
|
21
28
|
CentrsErrorEnvelope,
|
|
@@ -24,6 +31,7 @@ import type {
|
|
|
24
31
|
SettingSource as CoreSettingSource,
|
|
25
32
|
EnvelopeValidationMeta,
|
|
26
33
|
EnvelopeValidationStage,
|
|
34
|
+
StreamLineEnvelope,
|
|
27
35
|
Tip,
|
|
28
36
|
ValidationTrace,
|
|
29
37
|
Warning,
|
|
@@ -80,6 +88,7 @@ import {
|
|
|
80
88
|
type ResolvedAuth,
|
|
81
89
|
type ResolvedSetting,
|
|
82
90
|
type ResolvedTarget,
|
|
91
|
+
redactTargetInput,
|
|
83
92
|
resolveAuth,
|
|
84
93
|
resolveBooleanSetting,
|
|
85
94
|
resolveCdb,
|
|
@@ -213,19 +222,24 @@ export type ApiStreamStopReason =
|
|
|
213
222
|
| "transport-error";
|
|
214
223
|
|
|
215
224
|
/**
|
|
216
|
-
* Per-line marker on `--stream` NDJSON output:
|
|
217
|
-
* reply, or the terminating summary.
|
|
218
|
-
* comes first
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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.
|
|
223
236
|
*/
|
|
224
237
|
export type ApiStreamMeta =
|
|
225
|
-
| { kind: "
|
|
238
|
+
| { kind: "started" }
|
|
226
239
|
| {
|
|
227
240
|
kind: "frame";
|
|
228
241
|
index: number;
|
|
242
|
+
observedAt: string;
|
|
229
243
|
reply: "re" | "empty";
|
|
230
244
|
afterStop?: true;
|
|
231
245
|
}
|
|
@@ -265,6 +279,15 @@ export interface ApiOperationMeta {
|
|
|
265
279
|
}
|
|
266
280
|
|
|
267
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;
|
|
268
291
|
export type ApiSuccessEnvelope = CentrsSuccessEnvelope<
|
|
269
292
|
unknown,
|
|
270
293
|
ApiOperationMeta
|
|
@@ -311,6 +334,8 @@ export interface ResolvedApiRequest {
|
|
|
311
334
|
yes: boolean;
|
|
312
335
|
verbose: boolean;
|
|
313
336
|
warnings: readonly Warning[];
|
|
337
|
+
/** A CMR fleet command's reach, read before the write gate (#453). */
|
|
338
|
+
cmrFleet?: CmrFleetRead;
|
|
314
339
|
}
|
|
315
340
|
|
|
316
341
|
export async function api(
|
|
@@ -342,6 +367,7 @@ export async function apiEnvelope(
|
|
|
342
367
|
const trace: ValidationTrace = {};
|
|
343
368
|
try {
|
|
344
369
|
resolved = await resolveApiRequest(request, env);
|
|
370
|
+
resolved = await withCmrFleetScope(resolved);
|
|
345
371
|
await assertApiWriteConfirmed(request, resolved);
|
|
346
372
|
return await runResolvedApi(resolved, trace);
|
|
347
373
|
} catch (error) {
|
|
@@ -447,10 +473,11 @@ function apiRouterOsFailureFromResult(
|
|
|
447
473
|
}
|
|
448
474
|
|
|
449
475
|
/**
|
|
450
|
-
* The `--stream` path: an async generator yielding
|
|
451
|
-
* native reply (`!re` row or `!empty`)
|
|
452
|
-
* terminating summary envelope (`data` =
|
|
453
|
-
* 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.
|
|
454
481
|
* Streaming is native-api only — a `rest-api` (or otherwise unresolved)
|
|
455
482
|
* transport yields a single error envelope. `--count`/`--duration` and
|
|
456
483
|
* `externalSignal` (Ctrl-C) bound it. Errors before the first frame
|
|
@@ -464,7 +491,7 @@ export async function* apiStream(
|
|
|
464
491
|
env: Record<string, string | undefined> = Bun.env,
|
|
465
492
|
externalSignal?: AbortSignal,
|
|
466
493
|
onListening?: () => void,
|
|
467
|
-
): AsyncGenerator<
|
|
494
|
+
): AsyncGenerator<ApiStreamLine, void, void> {
|
|
468
495
|
// `apiStream` is inherently the streaming surface, so force `stream` on the
|
|
469
496
|
// request: otherwise a caller that omits it would resolve `via` to the
|
|
470
497
|
// rest-api default and be rejected. An explicit `--via rest-api` still
|
|
@@ -473,6 +500,7 @@ export async function* apiStream(
|
|
|
473
500
|
let resolved: ResolvedApiRequest | undefined;
|
|
474
501
|
try {
|
|
475
502
|
resolved = await resolveApiRequest(streamRequest, env);
|
|
503
|
+
resolved = await withCmrFleetScope(resolved);
|
|
476
504
|
await assertApiWriteConfirmed(streamRequest, resolved);
|
|
477
505
|
if (resolved.via.value !== "native-api") {
|
|
478
506
|
throw new CentrsError({
|
|
@@ -497,16 +525,18 @@ async function* streamResolvedApi(
|
|
|
497
525
|
request: ResolvedApiRequest,
|
|
498
526
|
externalSignal?: AbortSignal,
|
|
499
527
|
onListening?: () => void,
|
|
500
|
-
): AsyncGenerator<
|
|
528
|
+
): AsyncGenerator<ApiStreamLine, void, void> {
|
|
501
529
|
const backend = adapterForResolved(request);
|
|
502
530
|
|
|
503
531
|
let resolved = request;
|
|
504
532
|
let validation: EnvelopeValidationMeta;
|
|
533
|
+
let validationTips: Tip[] = [];
|
|
505
534
|
const trace: ValidationTrace = {};
|
|
506
535
|
try {
|
|
507
536
|
if (resolved.validate.value) {
|
|
508
537
|
const result = await validateApiRequest(resolved, backend, trace);
|
|
509
538
|
validation = result.validation;
|
|
539
|
+
validationTips = result.tips;
|
|
510
540
|
// The boolean probe below runs after the gate passed (GH#435).
|
|
511
541
|
trace.validation = validation;
|
|
512
542
|
resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
|
|
@@ -519,9 +549,27 @@ async function* streamResolvedApi(
|
|
|
519
549
|
return;
|
|
520
550
|
}
|
|
521
551
|
|
|
522
|
-
// Request-shape advice is known before the first reply: deliver it
|
|
523
|
-
// and keep it on whichever terminal summary follows (#409 review).
|
|
524
|
-
|
|
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
|
+
};
|
|
525
573
|
const controller = new AbortController();
|
|
526
574
|
const startedAt = Date.now();
|
|
527
575
|
const counts = { frames: 0, rows: 0, empty: 0 };
|
|
@@ -562,14 +610,17 @@ async function* streamResolvedApi(
|
|
|
562
610
|
}
|
|
563
611
|
|
|
564
612
|
try {
|
|
565
|
-
// A consumer may return immediately after this
|
|
613
|
+
// A consumer may return immediately after this line; keep it inside
|
|
566
614
|
// the session cleanup scope, just like every wire reply.
|
|
567
|
-
|
|
568
|
-
yield streamNoticeEnvelope(resolved, validation, tips);
|
|
569
|
-
}
|
|
615
|
+
yield streamStartedEnvelope(resolved, validation, tips);
|
|
570
616
|
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
571
617
|
for await (const reply of backend.stream(protocolRequest, {
|
|
572
618
|
signal: controller.signal,
|
|
619
|
+
onReply: (tapped) => {
|
|
620
|
+
if (tapped.type === "re" || tapped.type === "empty") {
|
|
621
|
+
arrivals.push(Date.now());
|
|
622
|
+
}
|
|
623
|
+
},
|
|
573
624
|
onListening: () => {
|
|
574
625
|
trace.dispatched = true;
|
|
575
626
|
onListening?.();
|
|
@@ -585,7 +636,7 @@ async function* streamResolvedApi(
|
|
|
585
636
|
counts.frames += 1;
|
|
586
637
|
if (reply.type === "empty") counts.empty += 1;
|
|
587
638
|
else counts.rows += 1;
|
|
588
|
-
yield streamFrameEnvelope(
|
|
639
|
+
yield streamFrameEnvelope(reply, counts.frames, nextArrival());
|
|
589
640
|
// `--count` counts rows only: an empty tick must never use up the
|
|
590
641
|
// count before the data it is waiting for arrives.
|
|
591
642
|
if (
|
|
@@ -638,29 +689,39 @@ async function* streamResolvedApi(
|
|
|
638
689
|
}
|
|
639
690
|
|
|
640
691
|
function streamFrameEnvelope(
|
|
641
|
-
resolved: ResolvedApiRequest,
|
|
642
|
-
validation: EnvelopeValidationMeta,
|
|
643
692
|
reply: ProtocolStreamReply,
|
|
644
693
|
index: number,
|
|
645
|
-
|
|
694
|
+
observedAt: number,
|
|
695
|
+
): ApiStreamFrameEnvelope {
|
|
646
696
|
const data = reply.type === "empty" ? null : reply.attributes;
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
},
|
|
653
715
|
};
|
|
654
|
-
return { ok: true, data, warnings: [], tips: [], meta };
|
|
655
716
|
}
|
|
656
717
|
|
|
657
|
-
function
|
|
718
|
+
function streamStartedEnvelope(
|
|
658
719
|
resolved: ResolvedApiRequest,
|
|
659
720
|
validation: EnvelopeValidationMeta,
|
|
660
721
|
tips: Tip[],
|
|
661
722
|
): ApiSuccessEnvelope {
|
|
662
723
|
const meta = metaFromResolved(resolved, validation, null);
|
|
663
|
-
meta.operation.stream = { kind: "
|
|
724
|
+
meta.operation.stream = { kind: "started" };
|
|
664
725
|
return { ok: true, data: null, warnings: [], tips, meta };
|
|
665
726
|
}
|
|
666
727
|
|
|
@@ -1092,8 +1153,9 @@ export function mapMethodToVerb(method: ApiMethod): ApiVerb {
|
|
|
1092
1153
|
}
|
|
1093
1154
|
|
|
1094
1155
|
/**
|
|
1095
|
-
* Read-only **iff** the method is `GET`,
|
|
1096
|
-
* `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
|
|
1097
1159
|
* on the verb/method, not the wire HTTP method — a `POST …/print` paged read does
|
|
1098
1160
|
* not prompt; `--listen` never relaxes the gate (a streaming-but-mutating
|
|
1099
1161
|
* `/system/license/renew` still confirms). See `commands/api/README.md`.
|
|
@@ -1102,6 +1164,13 @@ export function isApiMutating(method: ApiMethod, path: string): boolean {
|
|
|
1102
1164
|
if (method === "GET") {
|
|
1103
1165
|
return false;
|
|
1104
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
|
+
}
|
|
1105
1174
|
const terminal = pathTokens(path).at(-1);
|
|
1106
1175
|
if (terminal === "print" || terminal === "get" || terminal === "listen") {
|
|
1107
1176
|
return false;
|
|
@@ -1505,9 +1574,18 @@ async function assertApiWriteConfirmed(
|
|
|
1505
1574
|
if (!isApiMutating(resolved.method, resolved.path) || resolved.yes) {
|
|
1506
1575
|
return;
|
|
1507
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;
|
|
1508
1585
|
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
1509
1586
|
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
1510
|
-
|
|
1587
|
+
fleet?.prompt ??
|
|
1588
|
+
`Run mutating ${resolved.method} ${resolved.path} over ${resolved.via.value}? Type yes to continue: `,
|
|
1511
1589
|
);
|
|
1512
1590
|
if (confirmed) {
|
|
1513
1591
|
return;
|
|
@@ -1515,7 +1593,9 @@ async function assertApiWriteConfirmed(
|
|
|
1515
1593
|
}
|
|
1516
1594
|
throw new CentrsError({
|
|
1517
1595
|
code: "usage/confirmation-required",
|
|
1518
|
-
summary:
|
|
1596
|
+
summary:
|
|
1597
|
+
fleet?.summary ??
|
|
1598
|
+
`A mutating ${resolved.method} ${resolved.path} request requires explicit confirmation.`,
|
|
1519
1599
|
remediation:
|
|
1520
1600
|
"Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the request.",
|
|
1521
1601
|
context: {
|
|
@@ -1523,10 +1603,61 @@ async function assertApiWriteConfirmed(
|
|
|
1523
1603
|
path: resolved.path,
|
|
1524
1604
|
method: resolved.method,
|
|
1525
1605
|
via: resolved.via.value,
|
|
1606
|
+
...fleet?.context,
|
|
1526
1607
|
},
|
|
1527
1608
|
});
|
|
1528
1609
|
}
|
|
1529
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
|
+
|
|
1530
1661
|
function assertStreamCapability(resolved: ResolvedApiRequest): void {
|
|
1531
1662
|
if (!resolved.stream) {
|
|
1532
1663
|
return;
|
|
@@ -1545,7 +1676,7 @@ function assertStreamCapability(resolved: ResolvedApiRequest): void {
|
|
|
1545
1676
|
summary:
|
|
1546
1677
|
"`--stream` yields incremental replies instead of a one-shot `api` result.",
|
|
1547
1678
|
remediation:
|
|
1548
|
-
"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.",
|
|
1549
1680
|
context: { via: resolved.via.value, capability: "stream" },
|
|
1550
1681
|
});
|
|
1551
1682
|
}
|
|
@@ -1689,7 +1820,7 @@ export function buildApiErrorEnvelope(
|
|
|
1689
1820
|
warnings: [],
|
|
1690
1821
|
tips: [],
|
|
1691
1822
|
meta: {
|
|
1692
|
-
target: { input: request.targetInput },
|
|
1823
|
+
target: { input: redactTargetInput(request.targetInput) },
|
|
1693
1824
|
via: requestedVia,
|
|
1694
1825
|
settings: {},
|
|
1695
1826
|
operation: {
|
|
@@ -1930,7 +2061,7 @@ export function renderApiEnvelope(
|
|
|
1930
2061
|
return toYaml(envelope);
|
|
1931
2062
|
case "text":
|
|
1932
2063
|
return envelope.ok
|
|
1933
|
-
? renderApiSuccessText(envelope
|
|
2064
|
+
? renderApiSuccessText(envelope)
|
|
1934
2065
|
: formatCentrsErrorText(envelope.error, options);
|
|
1935
2066
|
default:
|
|
1936
2067
|
return exhaustiveFormat(format);
|
|
@@ -1942,11 +2073,12 @@ export function renderApiEnvelope(
|
|
|
1942
2073
|
* object per line) for both `json` and `yaml`, and a concise human row for
|
|
1943
2074
|
* `text`. `yaml` deliberately falls back to NDJSON here — YAML's multi-line
|
|
1944
2075
|
* document form is incompatible with line-delimited streaming. Unlike
|
|
1945
|
-
* {@link renderApiEnvelope} this never pretty-prints — every frame
|
|
1946
|
-
* 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.
|
|
1947
2079
|
*/
|
|
1948
2080
|
export function renderApiStreamLine(
|
|
1949
|
-
envelope:
|
|
2081
|
+
envelope: ApiStreamLine,
|
|
1950
2082
|
format: ApiOutputFormat,
|
|
1951
2083
|
options: { raw?: boolean; verbose?: boolean } = {},
|
|
1952
2084
|
): string {
|
|
@@ -1969,13 +2101,15 @@ export function renderApiStreamLine(
|
|
|
1969
2101
|
return JSON.stringify(envelope);
|
|
1970
2102
|
}
|
|
1971
2103
|
|
|
1972
|
-
function renderApiStreamFrameText(
|
|
2104
|
+
function renderApiStreamFrameText(
|
|
2105
|
+
envelope: Exclude<ApiStreamLine, ApiErrorEnvelope>,
|
|
2106
|
+
): string {
|
|
1973
2107
|
const stream = envelope.meta.operation?.stream;
|
|
1974
2108
|
if (stream?.kind === "summary") {
|
|
1975
2109
|
const empty = stream.empty > 0 ? `, ${stream.empty} empty` : "";
|
|
1976
2110
|
return `— ${stream.stopReason}: ${stream.rows} row(s)${empty} in ${stream.durationMs}ms`;
|
|
1977
2111
|
}
|
|
1978
|
-
if (stream?.kind === "
|
|
2112
|
+
if (stream?.kind === "started") {
|
|
1979
2113
|
return envelope.tips
|
|
1980
2114
|
.map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
|
|
1981
2115
|
.join("\n");
|
|
@@ -2003,24 +2137,24 @@ function rawErrorPayload(
|
|
|
2003
2137
|
return payload;
|
|
2004
2138
|
}
|
|
2005
2139
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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}`);
|
|
2017
2156
|
}
|
|
2018
|
-
lines
|
|
2019
|
-
typeof envelope.data === "string"
|
|
2020
|
-
? envelope.data
|
|
2021
|
-
: JSON.stringify(envelope.data, null, 2),
|
|
2022
|
-
);
|
|
2023
|
-
return lines.join("\n");
|
|
2157
|
+
return lines;
|
|
2024
2158
|
}
|
|
2025
2159
|
|
|
2026
2160
|
export function validateApiRequestShape(request: ApiRequest): void {
|
|
@@ -2115,11 +2249,17 @@ export function apiRequestSummaryFromRequest(
|
|
|
2115
2249
|
validate: summarizedValidate(request),
|
|
2116
2250
|
raw: request.raw ?? false,
|
|
2117
2251
|
format: resolveErrorFormat(request, env),
|
|
2118
|
-
|
|
2119
|
-
|
|
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)),
|
|
2120
2256
|
};
|
|
2121
2257
|
}
|
|
2122
2258
|
|
|
2259
|
+
function nonEmpty(list: string[] | undefined): string[] | undefined {
|
|
2260
|
+
return list && list.length > 0 ? list : undefined;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2123
2263
|
/** {@link buildApiQuery} for an error summary, which must not throw on the expression it reports. */
|
|
2124
2264
|
function summaryQuery(request: ApiRequest): string[] | undefined {
|
|
2125
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
|
},
|
package/src/cli/api.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
apiEnvelope,
|
|
5
5
|
apiOutputFormats,
|
|
6
6
|
apiStream,
|
|
7
|
+
apiVerboseLines,
|
|
7
8
|
buildApiErrorEnvelope,
|
|
8
9
|
normalizeApiEndpoint,
|
|
9
10
|
removedListenError,
|
|
@@ -26,6 +27,8 @@ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
|
|
|
26
27
|
import {
|
|
27
28
|
type CliCommandMetadata,
|
|
28
29
|
expectValue,
|
|
30
|
+
printTextHeader,
|
|
31
|
+
printTextNotes,
|
|
29
32
|
renderCommandHelp,
|
|
30
33
|
unknownFlagError,
|
|
31
34
|
} from "./common.ts";
|
|
@@ -106,7 +109,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
106
109
|
{
|
|
107
110
|
flag: "--stream",
|
|
108
111
|
description:
|
|
109
|
-
"Stream the command's native replies (`!re` rows and `!empty`) as NDJSON, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it.",
|
|
112
|
+
"Stream the command's native replies (`!re` rows and `!empty`) as NDJSON: a `started` line, a line per reply, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it.",
|
|
110
113
|
},
|
|
111
114
|
{
|
|
112
115
|
flag: "--count",
|
|
@@ -190,7 +193,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
190
193
|
},
|
|
191
194
|
{
|
|
192
195
|
flag: "--verbose",
|
|
193
|
-
description: "Include additional context in text output.",
|
|
196
|
+
description: "Include additional context in text output, on stderr.",
|
|
194
197
|
},
|
|
195
198
|
],
|
|
196
199
|
};
|
|
@@ -542,16 +545,22 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
542
545
|
)?.request?.format ??
|
|
543
546
|
parsed.format ??
|
|
544
547
|
"json";
|
|
548
|
+
// `--raw` is a bare payload by contract; `--json` carries the notes.
|
|
549
|
+
const text = resolvedFormat === "text" && !parsed.raw;
|
|
550
|
+
if (text && parsed.verbose) {
|
|
551
|
+
printTextHeader(apiVerboseLines(envelope));
|
|
552
|
+
}
|
|
545
553
|
const rendered = renderApiEnvelope(envelope, resolvedFormat, {
|
|
546
554
|
raw: parsed.raw,
|
|
547
555
|
verbose: parsed.verbose,
|
|
548
556
|
});
|
|
549
557
|
if (envelope.ok) {
|
|
550
558
|
console.log(rendered);
|
|
551
|
-
|
|
559
|
+
} else {
|
|
560
|
+
console.error(rendered);
|
|
552
561
|
}
|
|
553
|
-
|
|
554
|
-
return 1;
|
|
562
|
+
if (text) printTextNotes(envelope);
|
|
563
|
+
return envelope.ok ? 0 : 1;
|
|
555
564
|
} catch (error) {
|
|
556
565
|
const format = inferApiFormat(args, parsed);
|
|
557
566
|
const tips = isMissingTargetError(error)
|
|
@@ -566,7 +575,6 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
566
575
|
// missing `--input` file) instead of falling back to plain text.
|
|
567
576
|
const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
|
|
568
577
|
console.error(
|
|
569
|
-
// 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).
|
|
570
578
|
renderApiEnvelope(envelope, format, {
|
|
571
579
|
raw: true,
|
|
572
580
|
verbose: parsed.verbose ?? false,
|
|
@@ -578,14 +586,12 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
578
586
|
tips,
|
|
579
587
|
);
|
|
580
588
|
console.error(
|
|
581
|
-
// 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).
|
|
582
589
|
renderApiEnvelope(envelope, format, {
|
|
583
590
|
verbose: parsed?.verbose ?? false,
|
|
584
591
|
}),
|
|
585
592
|
);
|
|
586
593
|
} else {
|
|
587
594
|
console.error(
|
|
588
|
-
// 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).
|
|
589
595
|
formatCentrsErrorText(
|
|
590
596
|
asCentrsError(error, {
|
|
591
597
|
code: "input/invalid-command",
|
|
@@ -659,7 +665,6 @@ async function runApiFanoutCli(
|
|
|
659
665
|
Bun.env,
|
|
660
666
|
);
|
|
661
667
|
console.error(
|
|
662
|
-
// 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.
|
|
663
668
|
renderApiFanoutEnvelope(envelope, format, {
|
|
664
669
|
verbose: parsed.verbose ?? false,
|
|
665
670
|
}),
|
|
@@ -679,8 +684,9 @@ function endpointInfersListen(endpoint: string): boolean {
|
|
|
679
684
|
|
|
680
685
|
/**
|
|
681
686
|
* Emit all structured stream outcomes on stdout. Any failure sets exit 1,
|
|
682
|
-
* including a terminal error after successful rows.
|
|
683
|
-
* stderr error contract; Ctrl-C requests
|
|
687
|
+
* including a terminal error after successful rows. Text puts only frame rows
|
|
688
|
+
* on stdout; raw mode retains its stderr error contract; Ctrl-C requests
|
|
689
|
+
* bounded cancellation.
|
|
684
690
|
*/
|
|
685
691
|
async function runApiStreamCli(
|
|
686
692
|
parsed: ApiCliArgs,
|
|
@@ -697,17 +703,25 @@ async function runApiStreamCli(
|
|
|
697
703
|
Bun.env,
|
|
698
704
|
controller.signal,
|
|
699
705
|
)) {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
706
|
+
const kind = envelope.ok
|
|
707
|
+
? envelope.meta.operation?.stream?.kind
|
|
708
|
+
: undefined;
|
|
709
|
+
// `--raw` is bare payloads only; `started` has none.
|
|
710
|
+
if (parsed.raw && kind === "started") continue;
|
|
703
711
|
const line = renderApiStreamLine(envelope, format, {
|
|
704
712
|
raw: parsed.raw,
|
|
705
713
|
verbose: parsed.verbose,
|
|
706
714
|
});
|
|
707
|
-
if (
|
|
715
|
+
if (parsed.raw) {
|
|
716
|
+
if (envelope.ok) console.log(line);
|
|
717
|
+
else console.error(line);
|
|
718
|
+
} else if (format !== "text" || kind === "frame") {
|
|
708
719
|
console.log(line);
|
|
709
720
|
} else {
|
|
710
|
-
|
|
721
|
+
// Text stdout is data (#470, #473): status lines, tips, warnings
|
|
722
|
+
// and errors go to stderr. `started` renders its own tips.
|
|
723
|
+
if (line) console.error(line);
|
|
724
|
+
printTextNotes({ warnings: envelope.warnings, tips: [] });
|
|
711
725
|
}
|
|
712
726
|
if (!envelope.ok) exitCode = 1;
|
|
713
727
|
}
|