@tikoci/centrs 0.1.0 → 0.1.2
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 +73 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/devices/README.md +26 -0
- package/commands/discover/README.md +17 -8
- package/commands/discover/examples.md +10 -5
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/btest.ts +45 -1
- package/src/cli/common.ts +64 -0
- package/src/cli/discover.ts +26 -0
- package/src/cli/execute.ts +30 -4
- package/src/cli/missing-target.ts +171 -0
- package/src/cli/retrieve.ts +32 -14
- package/src/cli/terminal.ts +18 -4
- package/src/cli/transfer.ts +37 -9
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/discover.ts +96 -23
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
package/src/btest.ts
CHANGED
|
@@ -138,6 +138,8 @@ export interface BtestClientData {
|
|
|
138
138
|
stopReason: BtestStopReason;
|
|
139
139
|
serverUdpPort?: number;
|
|
140
140
|
clientUdpPort?: number;
|
|
141
|
+
/** TCP data connections actually driven (1 + joined secondaries). */
|
|
142
|
+
activeConnections: number;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
export interface BtestClientOperationMeta {
|
|
@@ -215,9 +217,37 @@ export async function btestClient(
|
|
|
215
217
|
controlPort,
|
|
216
218
|
};
|
|
217
219
|
|
|
220
|
+
const warnings: {
|
|
221
|
+
code: string;
|
|
222
|
+
message: string;
|
|
223
|
+
context?: Record<string, unknown>;
|
|
224
|
+
}[] = [];
|
|
225
|
+
|
|
218
226
|
try {
|
|
219
227
|
validateClientOptions(request);
|
|
220
228
|
|
|
229
|
+
// The btest wire command carries a single tx-size field; --remote-udp-tx-size
|
|
230
|
+
// and --local-udp-tx-size map to it separately only for receive (remote wins)
|
|
231
|
+
// and transmit (local wins). For both, localUdpTxSize wins and
|
|
232
|
+
// remoteUdpTxSize is silently discarded — warn when they differ so the user
|
|
233
|
+
// knows which size the server will actually use.
|
|
234
|
+
if (
|
|
235
|
+
protocol === "udp" &&
|
|
236
|
+
direction === "both" &&
|
|
237
|
+
request.remoteUdpTxSize !== undefined &&
|
|
238
|
+
request.localUdpTxSize !== undefined &&
|
|
239
|
+
request.remoteUdpTxSize !== request.localUdpTxSize
|
|
240
|
+
) {
|
|
241
|
+
warnings.push({
|
|
242
|
+
code: "routeros/btest-udp-tx-size-ignored",
|
|
243
|
+
message: `--remote-udp-tx-size (${request.remoteUdpTxSize}) is ignored for --direction both; the btest wire protocol carries a single tx-size and --local-udp-tx-size (${request.localUdpTxSize}) is used for both directions.`,
|
|
244
|
+
context: {
|
|
245
|
+
ignored: request.remoteUdpTxSize,
|
|
246
|
+
used: request.localUdpTxSize,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
221
251
|
const cdbResolution = await resolveCdb(
|
|
222
252
|
{
|
|
223
253
|
targetInput: request.targetInput,
|
|
@@ -273,6 +303,9 @@ export async function btestClient(
|
|
|
273
303
|
? { statusIntervalMs: request.intervalMs }
|
|
274
304
|
: {}),
|
|
275
305
|
...(txSize !== undefined ? { txSize } : {}),
|
|
306
|
+
...(request.connectionCount !== undefined
|
|
307
|
+
? { tcpConnectionCount: request.connectionCount }
|
|
308
|
+
: {}),
|
|
276
309
|
...(request.localTxSpeed !== undefined
|
|
277
310
|
? { localTxSpeed: request.localTxSpeed }
|
|
278
311
|
: {}),
|
|
@@ -301,6 +334,7 @@ export async function btestClient(
|
|
|
301
334
|
txTotalAvgBps: secs > 0 ? (summary.totalTxBytes * 8) / secs : 0,
|
|
302
335
|
rxTotalAvgBps: secs > 0 ? (summary.totalRxBytes * 8) / secs : 0,
|
|
303
336
|
stopReason: summary.stopReason,
|
|
337
|
+
activeConnections: summary.activeConnections,
|
|
304
338
|
};
|
|
305
339
|
if (summary.username !== undefined) data.user = summary.username;
|
|
306
340
|
if (summary.serverUdpPort !== undefined)
|
|
@@ -308,10 +342,36 @@ export async function btestClient(
|
|
|
308
342
|
if (summary.clientUdpPort !== undefined)
|
|
309
343
|
data.clientUdpPort = summary.clientUdpPort;
|
|
310
344
|
|
|
345
|
+
// TCP multi-connection: centrs now opens the negotiated extra data
|
|
346
|
+
// connections (fan-out). It still degrades to a single stream when the server
|
|
347
|
+
// negotiates no session token — notably authenticated (EC-SRP5) sessions,
|
|
348
|
+
// whose post-auth token centrs does not yet capture. Warn only when the
|
|
349
|
+
// realized fan-out fell short of the request, with the actual count.
|
|
350
|
+
if (
|
|
351
|
+
protocol === "tcp" &&
|
|
352
|
+
request.connectionCount !== undefined &&
|
|
353
|
+
request.connectionCount > 1 &&
|
|
354
|
+
summary.activeConnections < request.connectionCount
|
|
355
|
+
) {
|
|
356
|
+
const single = summary.activeConnections === 1;
|
|
357
|
+
warnings.push({
|
|
358
|
+
code: "routeros/btest-connection-count-single-stream",
|
|
359
|
+
message: `--connection-count (${request.connectionCount}) requested, but ${summary.activeConnections} TCP data connection(s) are active${
|
|
360
|
+
single && summary.authKind !== "none"
|
|
361
|
+
? " — authenticated multi-connection is not yet supported"
|
|
362
|
+
: ""
|
|
363
|
+
}; throughput will not scale to the full count.`,
|
|
364
|
+
context: {
|
|
365
|
+
requested: request.connectionCount,
|
|
366
|
+
active: summary.activeConnections,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
311
371
|
return {
|
|
312
372
|
ok: true,
|
|
313
373
|
data,
|
|
314
|
-
warnings:
|
|
374
|
+
warnings: warnings.map((w) => ({ ...w })),
|
|
315
375
|
tips: [],
|
|
316
376
|
meta: clientMeta(target.host, controlPort, operation, auth.username),
|
|
317
377
|
};
|
package/src/cli/api.ts
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ApiOutputFormat,
|
|
3
|
+
type ApiRequest,
|
|
4
|
+
apiEnvelope,
|
|
5
|
+
apiListen,
|
|
6
|
+
apiOutputFormats,
|
|
7
|
+
buildApiErrorEnvelope,
|
|
8
|
+
normalizeApiEndpoint,
|
|
9
|
+
renderApiEnvelope,
|
|
10
|
+
renderApiStreamLine,
|
|
11
|
+
} from "../api.ts";
|
|
12
|
+
import {
|
|
13
|
+
asCentrsError,
|
|
14
|
+
CentrsError,
|
|
15
|
+
formatCentrsErrorText,
|
|
16
|
+
} from "../errors.ts";
|
|
17
|
+
import { describeCentrs } from "../index.ts";
|
|
18
|
+
import {
|
|
19
|
+
type CliCommandMetadata,
|
|
20
|
+
expectValue,
|
|
21
|
+
renderCommandHelp,
|
|
22
|
+
unknownFlagError,
|
|
23
|
+
} from "./common.ts";
|
|
24
|
+
import {
|
|
25
|
+
buildTargetSelectionTips,
|
|
26
|
+
cdbFileFromArgs,
|
|
27
|
+
formatTipsText,
|
|
28
|
+
isMissingTargetError,
|
|
29
|
+
missingTargetError,
|
|
30
|
+
withTips,
|
|
31
|
+
} from "./missing-target.ts";
|
|
32
|
+
|
|
33
|
+
export const apiCommand: CliCommandMetadata = {
|
|
34
|
+
name: "api",
|
|
35
|
+
usage: "centrs api <router> <endpoint> [flags]",
|
|
36
|
+
summary:
|
|
37
|
+
"Structured RouterOS API passthrough (gh-api style) over REST or native API.",
|
|
38
|
+
options: [
|
|
39
|
+
{
|
|
40
|
+
flag: "-X / --method",
|
|
41
|
+
valueName: "<verb>",
|
|
42
|
+
description:
|
|
43
|
+
"HTTP method, default GET (case-insensitive). GET→print, PUT→add, PATCH→set, DELETE→remove, POST→run.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
flag: "-f",
|
|
47
|
+
valueName: "<key=value>",
|
|
48
|
+
description:
|
|
49
|
+
"Body field, repeatable; assembled into the JSON body (verbatim string values).",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
flag: "-d / --data",
|
|
53
|
+
valueName: "<json>",
|
|
54
|
+
description: "Raw JSON request body. Conflicts with `-f` / `--input`.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
flag: "--input",
|
|
58
|
+
valueName: "<file|->",
|
|
59
|
+
description: "Read the raw JSON body from a file or stdin (`-`).",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
flag: "--query / --filter",
|
|
63
|
+
valueName: "<expr>",
|
|
64
|
+
description:
|
|
65
|
+
"RouterOS-side row filter, AND-combined, repeatable: name=value, name!=value, name>value, name<value, name.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
flag: "--raw-query",
|
|
69
|
+
valueName: "<word>",
|
|
70
|
+
description:
|
|
71
|
+
"Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
flag: "--attribute / --proplist",
|
|
75
|
+
valueName: "<a,b>",
|
|
76
|
+
description: "Property projection → `.proplist`.",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
flag: "--raw",
|
|
80
|
+
description:
|
|
81
|
+
"Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does not imply `--yes`.",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
flag: "--yes",
|
|
85
|
+
description:
|
|
86
|
+
"Confirm a mutating (non-read) request in non-interactive runs.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
flag: "--stream / --listen",
|
|
90
|
+
description:
|
|
91
|
+
"Follow changes as an NDJSON envelope stream (native-api only; the `/listen` endpoint infers it). Ends with a summary envelope.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
flag: "--count",
|
|
95
|
+
valueName: "<n>",
|
|
96
|
+
description: "Stop a `--stream` after N change frames.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
flag: "--duration",
|
|
100
|
+
valueName: "<dur>",
|
|
101
|
+
description:
|
|
102
|
+
"Stop a `--stream` after this wall-clock window (e.g. `5s`).",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
flag: "--via",
|
|
106
|
+
valueName: "<rest-api|native-api>",
|
|
107
|
+
description: "Pin the transport; no silent downgrade. Default rest-api.",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
flag: "--host",
|
|
111
|
+
valueName: "<host|url>",
|
|
112
|
+
description: "Override the resolved host or base URL for the target.",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
flag: "--port",
|
|
116
|
+
valueName: "<port>",
|
|
117
|
+
description: "Override the resolved management port.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
flag: "--username / --user / -u",
|
|
121
|
+
valueName: "<name>",
|
|
122
|
+
description: "RouterOS username. Falls back to `CENTRS_USERNAME`.",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
flag: "--password",
|
|
126
|
+
valueName: "<secret>",
|
|
127
|
+
description: "RouterOS password. Falls back to `CENTRS_PASSWORD`.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
flag: "--insecure",
|
|
131
|
+
description:
|
|
132
|
+
"Accept a self-signed `api-ssl`/REST TLS cert. Default verifies.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
flag: "--cdb-file",
|
|
136
|
+
valueName: "<path>",
|
|
137
|
+
description: "Read target credentials from a WinBox CDB file.",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
flag: "--cdb-password",
|
|
141
|
+
valueName: "<password>",
|
|
142
|
+
description: "Decrypt an encrypted WinBox CDB file.",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
flag: "--resolve",
|
|
146
|
+
valueName: "<none|arp>",
|
|
147
|
+
description:
|
|
148
|
+
"Resolve a MAC-address target to an IP via the host ARP cache (default none).",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
flag: "--timeout",
|
|
152
|
+
valueName: "<duration>",
|
|
153
|
+
description: "Per-request timeout (for REST, max 60s).",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
flag: "--validate[=false]",
|
|
157
|
+
description:
|
|
158
|
+
"Run `/console/inspect` validation before the request (default true).",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
flag: "--format",
|
|
162
|
+
valueName: "<json|yaml|text>",
|
|
163
|
+
description:
|
|
164
|
+
"Output format. Defaults to json for api; `CENTRS_FORMAT` overrides.",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
flag: "--json",
|
|
168
|
+
description: "Shortcut for `--format json`.",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
flag: "--verbose",
|
|
172
|
+
description: "Include additional context in text output.",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
interface ApiCliArgs extends ApiRequest {
|
|
178
|
+
help?: boolean;
|
|
179
|
+
format?: ApiOutputFormat;
|
|
180
|
+
/** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
|
|
181
|
+
inputPath?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
185
|
+
const parsed: ApiCliArgs = { endpoint: "" };
|
|
186
|
+
const positional: string[] = [];
|
|
187
|
+
|
|
188
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
189
|
+
const arg = args[index];
|
|
190
|
+
if (arg === undefined) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (arg.startsWith("--validate=")) {
|
|
194
|
+
parsed.validate = parseBooleanFlag(arg.slice("--validate=".length), arg);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
switch (arg) {
|
|
198
|
+
case "--help":
|
|
199
|
+
case "-h":
|
|
200
|
+
parsed.help = true;
|
|
201
|
+
break;
|
|
202
|
+
case "--json":
|
|
203
|
+
parsed.format = "json";
|
|
204
|
+
break;
|
|
205
|
+
case "--verbose":
|
|
206
|
+
parsed.verbose = true;
|
|
207
|
+
break;
|
|
208
|
+
case "--yes":
|
|
209
|
+
parsed.yes = true;
|
|
210
|
+
break;
|
|
211
|
+
case "--raw":
|
|
212
|
+
parsed.raw = true;
|
|
213
|
+
break;
|
|
214
|
+
case "--stream":
|
|
215
|
+
case "--listen":
|
|
216
|
+
parsed.listen = true;
|
|
217
|
+
break;
|
|
218
|
+
case "--validate":
|
|
219
|
+
parsed.validate = true;
|
|
220
|
+
break;
|
|
221
|
+
case "--no-validate":
|
|
222
|
+
parsed.validate = false;
|
|
223
|
+
break;
|
|
224
|
+
case "-X":
|
|
225
|
+
case "--method":
|
|
226
|
+
parsed.method = expectValue(args, ++index, arg);
|
|
227
|
+
break;
|
|
228
|
+
case "-f":
|
|
229
|
+
case "--field": {
|
|
230
|
+
const kv = expectValue(args, ++index, arg);
|
|
231
|
+
const eq = kv.indexOf("=");
|
|
232
|
+
if (eq <= 0) {
|
|
233
|
+
throw new Error(`${arg} expects key=value; got ${kv}`);
|
|
234
|
+
}
|
|
235
|
+
parsed.fields = {
|
|
236
|
+
...(parsed.fields ?? {}),
|
|
237
|
+
[kv.slice(0, eq)]: kv.slice(eq + 1),
|
|
238
|
+
};
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case "-d":
|
|
242
|
+
case "--data":
|
|
243
|
+
parsed.data = expectValue(args, ++index, arg);
|
|
244
|
+
break;
|
|
245
|
+
case "--input":
|
|
246
|
+
parsed.inputPath = expectValue(args, ++index, arg);
|
|
247
|
+
break;
|
|
248
|
+
case "--query":
|
|
249
|
+
case "--filter":
|
|
250
|
+
parsed.query = [
|
|
251
|
+
...(parsed.query ?? []),
|
|
252
|
+
expectValue(args, ++index, arg),
|
|
253
|
+
];
|
|
254
|
+
break;
|
|
255
|
+
case "--raw-query":
|
|
256
|
+
parsed.rawQuery = [
|
|
257
|
+
...(parsed.rawQuery ?? []),
|
|
258
|
+
expectValue(args, ++index, arg),
|
|
259
|
+
];
|
|
260
|
+
break;
|
|
261
|
+
case "--attribute":
|
|
262
|
+
case "--proplist":
|
|
263
|
+
parsed.proplist = [
|
|
264
|
+
...(parsed.proplist ?? []),
|
|
265
|
+
expectValue(args, ++index, arg),
|
|
266
|
+
];
|
|
267
|
+
break;
|
|
268
|
+
case "--duration":
|
|
269
|
+
parsed.duration = expectValue(args, ++index, arg);
|
|
270
|
+
break;
|
|
271
|
+
case "--count":
|
|
272
|
+
parsed.count = parseIntegerFlag(expectValue(args, ++index, arg), arg);
|
|
273
|
+
break;
|
|
274
|
+
case "--via":
|
|
275
|
+
parsed.via = expectValue(args, ++index, arg);
|
|
276
|
+
break;
|
|
277
|
+
case "--host":
|
|
278
|
+
parsed.host = expectValue(args, ++index, arg);
|
|
279
|
+
break;
|
|
280
|
+
case "--port":
|
|
281
|
+
parsed.port = parseIntegerFlag(expectValue(args, ++index, arg), arg);
|
|
282
|
+
break;
|
|
283
|
+
case "--user":
|
|
284
|
+
case "-u":
|
|
285
|
+
case "--username":
|
|
286
|
+
parsed.username = expectValue(args, ++index, arg);
|
|
287
|
+
break;
|
|
288
|
+
case "--password":
|
|
289
|
+
parsed.password = expectValue(args, ++index, arg);
|
|
290
|
+
break;
|
|
291
|
+
case "--insecure":
|
|
292
|
+
parsed.insecure = true;
|
|
293
|
+
break;
|
|
294
|
+
case "--cdb-file":
|
|
295
|
+
parsed.cdbFile = expectValue(args, ++index, arg);
|
|
296
|
+
break;
|
|
297
|
+
case "--cdb-password":
|
|
298
|
+
parsed.cdbPassword = expectValue(args, ++index, arg);
|
|
299
|
+
break;
|
|
300
|
+
case "--resolve":
|
|
301
|
+
parsed.resolve = expectValue(args, ++index, arg);
|
|
302
|
+
break;
|
|
303
|
+
case "--timeout":
|
|
304
|
+
parsed.timeout = expectValue(args, ++index, arg);
|
|
305
|
+
break;
|
|
306
|
+
case "--format": {
|
|
307
|
+
const value = expectValue(args, ++index, arg);
|
|
308
|
+
if (!apiOutputFormats.includes(value as ApiOutputFormat)) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`--format must be one of ${apiOutputFormats.join(", ")}; got ${value}.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
parsed.format = value as ApiOutputFormat;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
default:
|
|
317
|
+
if (arg.startsWith("-")) {
|
|
318
|
+
throw unknownFlagError("api", arg, apiCommand.options);
|
|
319
|
+
}
|
|
320
|
+
positional.push(arg);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
parsed.targetInput = positional[0];
|
|
326
|
+
parsed.endpoint = positional[1] ?? "";
|
|
327
|
+
parsed.stdinIsTty = process.stdin.isTTY;
|
|
328
|
+
return parsed;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
332
|
+
let parsed: ApiCliArgs | undefined;
|
|
333
|
+
try {
|
|
334
|
+
parsed = parseApiCliArgs(args);
|
|
335
|
+
if (parsed.help) {
|
|
336
|
+
console.log(renderCommandHelp(describeCentrs(), apiCommand));
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
if (!parsed.targetInput) {
|
|
340
|
+
throw missingTargetError({
|
|
341
|
+
command: "api",
|
|
342
|
+
summary: "`centrs api` requires a <router> and an <endpoint>.",
|
|
343
|
+
remediation:
|
|
344
|
+
"Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// Resolve `--input` (file / stdin) before handing the body to the orchestrator.
|
|
348
|
+
if (parsed.inputPath !== undefined) {
|
|
349
|
+
const inputPath = parsed.inputPath;
|
|
350
|
+
try {
|
|
351
|
+
parsed.inputBody =
|
|
352
|
+
inputPath === "-"
|
|
353
|
+
? await Bun.stdin.text()
|
|
354
|
+
: await Bun.file(inputPath).text();
|
|
355
|
+
} catch (error) {
|
|
356
|
+
throw new CentrsError({
|
|
357
|
+
code: "input/local-file-not-found",
|
|
358
|
+
summary: `Cannot read the --input body from ${inputPath === "-" ? "stdin" : inputPath}.`,
|
|
359
|
+
remediation:
|
|
360
|
+
"Check the path and read permissions, or pipe the JSON body into `--input -`.",
|
|
361
|
+
context: { input: inputPath },
|
|
362
|
+
cause: error,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
|
|
368
|
+
// the NDJSON envelope stream instead of a single one-shot envelope.
|
|
369
|
+
const streaming =
|
|
370
|
+
parsed.listen === true ||
|
|
371
|
+
(parsed.endpoint.length > 0 &&
|
|
372
|
+
normalizeApiEndpoint(parsed.endpoint).listen);
|
|
373
|
+
if (streaming) {
|
|
374
|
+
return await runApiListenCli(parsed, args);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const envelope = await apiEnvelope(parsed);
|
|
378
|
+
const resolvedFormat =
|
|
379
|
+
(
|
|
380
|
+
envelope.meta.operation as {
|
|
381
|
+
request?: { format?: ApiOutputFormat };
|
|
382
|
+
}
|
|
383
|
+
)?.request?.format ??
|
|
384
|
+
parsed.format ??
|
|
385
|
+
"json";
|
|
386
|
+
const rendered = renderApiEnvelope(envelope, resolvedFormat, {
|
|
387
|
+
raw: parsed.raw,
|
|
388
|
+
verbose: parsed.verbose,
|
|
389
|
+
});
|
|
390
|
+
if (envelope.ok) {
|
|
391
|
+
console.log(rendered);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
console.error(rendered);
|
|
395
|
+
return 1;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
const format = inferApiFormat(args, parsed);
|
|
398
|
+
const tips = isMissingTargetError(error)
|
|
399
|
+
? await buildTargetSelectionTips({
|
|
400
|
+
cdbFile: parsed?.cdbFile ?? cdbFileFromArgs(args),
|
|
401
|
+
env: Bun.env,
|
|
402
|
+
})
|
|
403
|
+
: [];
|
|
404
|
+
if (parsed?.raw) {
|
|
405
|
+
// `--raw` defines a compact JSON error contract (code/message → stderr);
|
|
406
|
+
// keep that machine shape on the pre-envelope failure path too (e.g. a
|
|
407
|
+
// missing `--input` file) instead of falling back to plain text.
|
|
408
|
+
const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
|
|
409
|
+
console.error(
|
|
410
|
+
renderApiEnvelope(envelope, format, {
|
|
411
|
+
raw: true,
|
|
412
|
+
verbose: parsed.verbose ?? false,
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
} else if (format === "json" || format === "yaml") {
|
|
416
|
+
const envelope = withTips(
|
|
417
|
+
buildApiErrorEnvelope(parsed ?? { endpoint: "" }, error),
|
|
418
|
+
tips,
|
|
419
|
+
);
|
|
420
|
+
console.error(
|
|
421
|
+
renderApiEnvelope(envelope, format, {
|
|
422
|
+
verbose: parsed?.verbose ?? false,
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
console.error(
|
|
427
|
+
formatCentrsErrorText(
|
|
428
|
+
asCentrsError(error, {
|
|
429
|
+
code: "input/invalid-command",
|
|
430
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
431
|
+
remediation:
|
|
432
|
+
"Use `centrs api --help` to inspect the supported endpoint shape and flags.",
|
|
433
|
+
}),
|
|
434
|
+
{ verbose: parsed?.verbose ?? args.includes("--verbose") },
|
|
435
|
+
) + formatTipsText(tips),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Consume the open-ended `--stream` follow: print one line per change frame
|
|
444
|
+
* (NDJSON under `json`, human rows under `text`), then the terminating summary.
|
|
445
|
+
* Ctrl-C (SIGINT) cancels the listen and still emits the summary. The exit code
|
|
446
|
+
* reflects whether the stream *started* cleanly — a failure before the first
|
|
447
|
+
* frame is `1`; a mid-stream error frame leaves a started stream at `0`.
|
|
448
|
+
*/
|
|
449
|
+
async function runApiListenCli(
|
|
450
|
+
parsed: ApiCliArgs,
|
|
451
|
+
args: readonly string[],
|
|
452
|
+
): Promise<number> {
|
|
453
|
+
const format = inferApiFormat(args, parsed);
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
const onSigint = (): void => controller.abort();
|
|
456
|
+
process.on("SIGINT", onSigint);
|
|
457
|
+
let exitCode = 0;
|
|
458
|
+
let first = true;
|
|
459
|
+
try {
|
|
460
|
+
for await (const envelope of apiListen(
|
|
461
|
+
parsed,
|
|
462
|
+
Bun.env,
|
|
463
|
+
controller.signal,
|
|
464
|
+
)) {
|
|
465
|
+
const line = renderApiStreamLine(envelope, format, {
|
|
466
|
+
raw: parsed.raw,
|
|
467
|
+
verbose: parsed.verbose,
|
|
468
|
+
});
|
|
469
|
+
if (envelope.ok) {
|
|
470
|
+
console.log(line);
|
|
471
|
+
} else {
|
|
472
|
+
console.error(line);
|
|
473
|
+
if (first) {
|
|
474
|
+
exitCode = 1;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
first = false;
|
|
478
|
+
}
|
|
479
|
+
} finally {
|
|
480
|
+
process.off("SIGINT", onSigint);
|
|
481
|
+
}
|
|
482
|
+
return exitCode;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function inferApiFormat(
|
|
486
|
+
args: readonly string[],
|
|
487
|
+
parsed?: ApiCliArgs,
|
|
488
|
+
): ApiOutputFormat {
|
|
489
|
+
if (parsed?.format) {
|
|
490
|
+
return parsed.format;
|
|
491
|
+
}
|
|
492
|
+
if (args.includes("--json")) {
|
|
493
|
+
return "json";
|
|
494
|
+
}
|
|
495
|
+
const index = args.indexOf("--format");
|
|
496
|
+
if (index >= 0) {
|
|
497
|
+
const value = args[index + 1];
|
|
498
|
+
if (
|
|
499
|
+
value !== undefined &&
|
|
500
|
+
apiOutputFormats.includes(value as ApiOutputFormat)
|
|
501
|
+
) {
|
|
502
|
+
return value as ApiOutputFormat;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Honor CENTRS_FORMAT on the parse-time error path so the catch render matches
|
|
506
|
+
// buildApiErrorEnvelope's env-aware `meta.operation.request.format`. CLI flags
|
|
507
|
+
// above still win.
|
|
508
|
+
const envFormat = Bun.env["CENTRS_FORMAT"];
|
|
509
|
+
if (
|
|
510
|
+
envFormat !== undefined &&
|
|
511
|
+
apiOutputFormats.includes(envFormat as ApiOutputFormat)
|
|
512
|
+
) {
|
|
513
|
+
return envFormat as ApiOutputFormat;
|
|
514
|
+
}
|
|
515
|
+
// api is machine-first: default to the structured json envelope, not text.
|
|
516
|
+
return "json";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function parseIntegerFlag(value: string, flag: string): number {
|
|
520
|
+
// Validate the whole token: `Number.parseInt` would silently accept prefixes
|
|
521
|
+
// like `8728ms` / `10abc` and send a request to the wrong port/count.
|
|
522
|
+
if (!/^-?\d+$/.test(value.trim())) {
|
|
523
|
+
throw new Error(`${flag} must be an integer; got ${value}.`);
|
|
524
|
+
}
|
|
525
|
+
return Number.parseInt(value, 10);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function parseBooleanFlag(value: string, flag: string): boolean {
|
|
529
|
+
if (["true", "1", "yes", "on"].includes(value.toLowerCase())) {
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
if (["false", "0", "no", "off"].includes(value.toLowerCase())) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
throw new Error(`${flag} must be true or false; got ${value}.`);
|
|
536
|
+
}
|
package/src/cli/btest.ts
CHANGED
|
@@ -26,11 +26,19 @@ import {
|
|
|
26
26
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
27
27
|
import { describeCentrs, parseDuration } from "../index.ts";
|
|
28
28
|
import type { BtestDirection, BtestProtocol } from "../protocols/btest.ts";
|
|
29
|
+
import { toYaml } from "../retrieve.ts";
|
|
29
30
|
import {
|
|
30
31
|
type CliCommandMetadata,
|
|
31
32
|
expectValue,
|
|
32
33
|
renderCommandHelp,
|
|
33
34
|
} from "./common.ts";
|
|
35
|
+
import {
|
|
36
|
+
buildTargetSelectionTips,
|
|
37
|
+
cdbFileFromArgs,
|
|
38
|
+
formatTipsText,
|
|
39
|
+
isMissingTargetError,
|
|
40
|
+
missingTargetError,
|
|
41
|
+
} from "./missing-target.ts";
|
|
34
42
|
|
|
35
43
|
export const btestCommand: CliCommandMetadata = {
|
|
36
44
|
name: "btest",
|
|
@@ -355,6 +363,14 @@ async function runClient(args: readonly string[]): Promise<number> {
|
|
|
355
363
|
console.log(renderCommandHelp(describeCentrs(), btestCommand));
|
|
356
364
|
return 0;
|
|
357
365
|
}
|
|
366
|
+
if (request.targetInput === undefined) {
|
|
367
|
+
throw missingTargetError({
|
|
368
|
+
command: "btest client",
|
|
369
|
+
summary: "`centrs btest client` requires a <router> target.",
|
|
370
|
+
remediation:
|
|
371
|
+
"Pass the bandwidth-test server's host/identity, e.g. `centrs btest client 192.0.2.10`.",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
358
374
|
const streaming = format === "text" || format === "csv";
|
|
359
375
|
let csvHeader = false;
|
|
360
376
|
if (streaming) {
|
|
@@ -444,12 +460,40 @@ export async function runBtestCli(args: readonly string[]): Promise<number> {
|
|
|
444
460
|
`Unknown btest sub-command: ${sub}. Use \`client <router>\` or \`server\`.`,
|
|
445
461
|
);
|
|
446
462
|
} catch (error) {
|
|
463
|
+
// The parsed request (not present here) holds the raw password for auth;
|
|
464
|
+
// errors at this level are missing-target or arg-parse failures — neither
|
|
465
|
+
// carries credentials. CodeQL follows the password parsed earlier in
|
|
466
|
+
// runBtestCli and flags any console.error that touches centrsError; the
|
|
467
|
+
// password reaches neither the error nor these renderers, so the alerts are
|
|
468
|
+
// suppressed inline (same as the matching dismissals on terminal/transfer/execute).
|
|
447
469
|
const centrsError = asCentrsError(error, {
|
|
448
470
|
code: "input/invalid-command",
|
|
449
471
|
summary: error instanceof Error ? error.message : String(error),
|
|
450
472
|
remediation: "Use `centrs btest --help` to inspect the supported flags.",
|
|
451
473
|
});
|
|
452
|
-
|
|
474
|
+
const tips = isMissingTargetError(centrsError)
|
|
475
|
+
? await buildTargetSelectionTips({
|
|
476
|
+
cdbFile: cdbFileFromArgs(args),
|
|
477
|
+
env: Bun.env,
|
|
478
|
+
})
|
|
479
|
+
: [];
|
|
480
|
+
const format = args.includes("--json")
|
|
481
|
+
? "json"
|
|
482
|
+
: args.includes("--yaml")
|
|
483
|
+
? "yaml"
|
|
484
|
+
: "text";
|
|
485
|
+
if (format === "json" || format === "yaml") {
|
|
486
|
+
const out = {
|
|
487
|
+
ok: false as const,
|
|
488
|
+
error: { code: centrsError.code },
|
|
489
|
+
tips,
|
|
490
|
+
};
|
|
491
|
+
const serialized = format === "yaml" ? toYaml(out) : JSON.stringify(out);
|
|
492
|
+
console.error(serialized); // lgtm[js/clear-text-logging]
|
|
493
|
+
} else {
|
|
494
|
+
const text = formatCentrsErrorText(centrsError) + formatTipsText(tips);
|
|
495
|
+
console.error(text); // lgtm[js/clear-text-logging]
|
|
496
|
+
}
|
|
453
497
|
return 1;
|
|
454
498
|
}
|
|
455
499
|
}
|