@tikoci/centrs 0.1.1 → 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 +48 -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/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/common.ts +64 -0
- 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/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/common.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* together.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { CentrsError } from "../errors.ts";
|
|
10
|
+
|
|
9
11
|
export interface CliCommandOption {
|
|
10
12
|
flag: string;
|
|
11
13
|
valueName?: string;
|
|
@@ -31,6 +33,68 @@ export function expectValue(
|
|
|
31
33
|
return value;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/** Flatten an option's `flag` field ("-X / --method") into individual `-`/`--` tokens. */
|
|
37
|
+
export function knownFlags(options: readonly CliCommandOption[]): string[] {
|
|
38
|
+
const flags: string[] = [];
|
|
39
|
+
for (const option of options) {
|
|
40
|
+
for (const token of option.flag.split("/")) {
|
|
41
|
+
const trimmed = token.trim();
|
|
42
|
+
if (trimmed.startsWith("-")) {
|
|
43
|
+
flags.push(trimmed);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return flags;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function editDistance(a: string, b: string): number {
|
|
51
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
52
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
53
|
+
const curr = [i];
|
|
54
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
55
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
56
|
+
curr[j] = Math.min(
|
|
57
|
+
(curr[j - 1] ?? 0) + 1,
|
|
58
|
+
(prev[j] ?? 0) + 1,
|
|
59
|
+
(prev[j - 1] ?? 0) + cost,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
prev = curr;
|
|
63
|
+
}
|
|
64
|
+
return prev[b.length] ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Closest known flags to `arg` (Levenshtein ≤ 2 — tight enough to avoid noise), nearest first, capped at 3. */
|
|
68
|
+
export function closestFlags(arg: string, flags: readonly string[]): string[] {
|
|
69
|
+
return [...new Set(flags)]
|
|
70
|
+
.map((flag) => ({ flag, distance: editDistance(arg, flag) }))
|
|
71
|
+
.filter(({ distance }) => distance <= 2)
|
|
72
|
+
.sort((left, right) => left.distance - right.distance)
|
|
73
|
+
.slice(0, 3)
|
|
74
|
+
.map(({ flag }) => flag);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Actionable "unknown flag" error: lists the closest canonical matches (and
|
|
79
|
+
* aliases) so humans and agents can recover (commands/AGENTS.md → "Did you
|
|
80
|
+
* mean?"). Shared seam — parsers pass their own `command` + option metadata.
|
|
81
|
+
*/
|
|
82
|
+
export function unknownFlagError(
|
|
83
|
+
command: string,
|
|
84
|
+
arg: string,
|
|
85
|
+
options: readonly CliCommandOption[],
|
|
86
|
+
): CentrsError {
|
|
87
|
+
const suggestions = closestFlags(arg, knownFlags(options));
|
|
88
|
+
const hint =
|
|
89
|
+
suggestions.length > 0 ? ` Did you mean ${suggestions.join(", ")}?` : "";
|
|
90
|
+
return new CentrsError({
|
|
91
|
+
code: "input/invalid-command",
|
|
92
|
+
summary: `Unknown ${command} flag: ${arg}.${hint}`,
|
|
93
|
+
remediation: `Run \`centrs ${command} --help\` to see the supported flags${suggestions.length > 0 ? " and their aliases" : ""}.`,
|
|
94
|
+
context: { flag: arg, suggestions },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
34
98
|
export function renderCommandHelp(
|
|
35
99
|
describe: string,
|
|
36
100
|
command: CliCommandMetadata,
|