@tikoci/centrs 0.1.6 → 0.1.8-next.0
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 +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +84 -18
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
package/src/btest.ts
CHANGED
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
import { createServer } from "node:net";
|
|
18
18
|
import type { CentrsEnvelope, EnvelopeMeta } from "./core/envelope.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
asCentrsError,
|
|
21
|
+
CentrsError,
|
|
22
|
+
formatCentrsErrorText,
|
|
23
|
+
serializeCentrsError,
|
|
24
|
+
terminalSafeLine,
|
|
25
|
+
} from "./errors.ts";
|
|
20
26
|
import {
|
|
21
27
|
BTEST_PORT,
|
|
22
28
|
BTEST_UDP_PORT_START,
|
|
@@ -868,7 +874,11 @@ function renderClientCsv(envelope: BtestClientEnvelope): string {
|
|
|
868
874
|
if (envelope.ok)
|
|
869
875
|
for (const r of envelope.data.reports) lines.push(btestClientCsvRow(r));
|
|
870
876
|
else
|
|
871
|
-
lines.push(
|
|
877
|
+
lines.push(
|
|
878
|
+
terminalSafeLine(
|
|
879
|
+
`# error: [${envelope.error.code}] ${envelope.error.summary}`,
|
|
880
|
+
),
|
|
881
|
+
);
|
|
872
882
|
return lines.join("\n");
|
|
873
883
|
}
|
|
874
884
|
|
|
@@ -877,7 +887,11 @@ function renderServerCsv(envelope: BtestServerEnvelope): string {
|
|
|
877
887
|
if (envelope.ok)
|
|
878
888
|
for (const s of envelope.data.sessions) lines.push(btestServerCsvRow(s));
|
|
879
889
|
else
|
|
880
|
-
lines.push(
|
|
890
|
+
lines.push(
|
|
891
|
+
terminalSafeLine(
|
|
892
|
+
`# error: [${envelope.error.code}] ${envelope.error.summary}`,
|
|
893
|
+
),
|
|
894
|
+
);
|
|
881
895
|
return lines.join("\n");
|
|
882
896
|
}
|
|
883
897
|
|
|
@@ -902,14 +916,14 @@ export function btestServerSummaryLine(d: BtestServerData): string {
|
|
|
902
916
|
}
|
|
903
917
|
|
|
904
918
|
function renderClientText(envelope: BtestClientEnvelope): string {
|
|
905
|
-
if (!envelope.ok) return
|
|
919
|
+
if (!envelope.ok) return formatCentrsErrorText(envelope.error);
|
|
906
920
|
const lines = envelope.data.reports.map(formatBtestReportText);
|
|
907
921
|
lines.push(btestClientSummaryLine(envelope.data));
|
|
908
922
|
return lines.join("\n");
|
|
909
923
|
}
|
|
910
924
|
|
|
911
925
|
function renderServerText(envelope: BtestServerEnvelope): string {
|
|
912
|
-
if (!envelope.ok) return
|
|
926
|
+
if (!envelope.ok) return formatCentrsErrorText(envelope.error);
|
|
913
927
|
const lines = [btestServerSummaryLine(envelope.data)];
|
|
914
928
|
for (const s of envelope.data.sessions) lines.push(formatBtestSessionText(s));
|
|
915
929
|
for (const w of envelope.warnings)
|
|
@@ -917,18 +931,6 @@ function renderServerText(envelope: BtestServerEnvelope): string {
|
|
|
917
931
|
return lines.join("\n");
|
|
918
932
|
}
|
|
919
933
|
|
|
920
|
-
function formatErrorText(error: {
|
|
921
|
-
code: string;
|
|
922
|
-
summary: string;
|
|
923
|
-
remediation?: string;
|
|
924
|
-
detailsUrl?: string;
|
|
925
|
-
}): string {
|
|
926
|
-
const lines = [`[${error.code}] ${error.summary}`];
|
|
927
|
-
if (error.remediation) lines.push(`Fix: ${error.remediation}`);
|
|
928
|
-
if (error.detailsUrl) lines.push(`Details: ${error.detailsUrl}`);
|
|
929
|
-
return lines.join("\n");
|
|
930
|
-
}
|
|
931
|
-
|
|
932
934
|
// Compact YAML renderer (per-command, matching the discover surface).
|
|
933
935
|
function renderYaml(value: unknown, indent = ""): string {
|
|
934
936
|
if (value === null || value === undefined) return `${indent}null`;
|
package/src/cli/api.ts
CHANGED
|
@@ -2,10 +2,11 @@ import {
|
|
|
2
2
|
type ApiOutputFormat,
|
|
3
3
|
type ApiRequest,
|
|
4
4
|
apiEnvelope,
|
|
5
|
-
apiListen,
|
|
6
5
|
apiOutputFormats,
|
|
6
|
+
apiStream,
|
|
7
7
|
buildApiErrorEnvelope,
|
|
8
8
|
normalizeApiEndpoint,
|
|
9
|
+
removedListenError,
|
|
9
10
|
renderApiEnvelope,
|
|
10
11
|
renderApiStreamLine,
|
|
11
12
|
} from "../api.ts";
|
|
@@ -48,7 +49,8 @@ import {
|
|
|
48
49
|
|
|
49
50
|
export const apiCommand: CliCommandMetadata = {
|
|
50
51
|
name: "api",
|
|
51
|
-
usage:
|
|
52
|
+
usage:
|
|
53
|
+
"centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <endpoint> [flags]",
|
|
52
54
|
summary:
|
|
53
55
|
"Structured RouterOS API passthrough (gh-api style) over REST or native API.",
|
|
54
56
|
options: [
|
|
@@ -78,13 +80,13 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
78
80
|
flag: "--query / --filter",
|
|
79
81
|
valueName: "<expr>",
|
|
80
82
|
description:
|
|
81
|
-
"
|
|
83
|
+
"Row filter written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`), sent as query words; repeatable, AND-ed. No `~` or `in`.",
|
|
82
84
|
},
|
|
83
85
|
{
|
|
84
86
|
flag: "--raw-query",
|
|
85
87
|
valueName: "<word>",
|
|
86
88
|
description:
|
|
87
|
-
"Verbatim RouterOS query word (repeatable)
|
|
89
|
+
"Verbatim RouterOS query word (repeatable), e.g. has-property `name`, absence `-name`, or stack ops `#|` `#&` `#!`.",
|
|
88
90
|
},
|
|
89
91
|
{
|
|
90
92
|
flag: "--attribute / --proplist",
|
|
@@ -102,14 +104,15 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
102
104
|
"Confirm a mutating (non-read) request in non-interactive runs.",
|
|
103
105
|
},
|
|
104
106
|
{
|
|
105
|
-
flag: "--stream
|
|
107
|
+
flag: "--stream",
|
|
106
108
|
description:
|
|
107
|
-
"
|
|
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.",
|
|
108
110
|
},
|
|
109
111
|
{
|
|
110
112
|
flag: "--count",
|
|
111
113
|
valueName: "<n>",
|
|
112
|
-
description:
|
|
114
|
+
description:
|
|
115
|
+
"Stop a `--stream` after N `!re` rows; `!empty` replies do not count (not the device count= argument).",
|
|
113
116
|
},
|
|
114
117
|
{
|
|
115
118
|
flag: "--duration",
|
|
@@ -126,7 +129,8 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
126
129
|
{
|
|
127
130
|
flag: "--host",
|
|
128
131
|
valueName: "<host|url>",
|
|
129
|
-
description:
|
|
132
|
+
description:
|
|
133
|
+
"Host or base URL to dial. With no <router> it is the target (no CDB lookup); with one it overrides the resolved host.",
|
|
130
134
|
},
|
|
131
135
|
{
|
|
132
136
|
flag: "--port",
|
|
@@ -176,7 +180,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
176
180
|
},
|
|
177
181
|
{
|
|
178
182
|
flag: "--format",
|
|
179
|
-
valueName: "<json|yaml|text>",
|
|
183
|
+
valueName: "<json|ndjson|yaml|text>",
|
|
180
184
|
description:
|
|
181
185
|
"Output format. Defaults to json for api; `CENTRS_FORMAT` overrides.",
|
|
182
186
|
},
|
|
@@ -205,7 +209,9 @@ interface ApiCliArgs extends ApiRequest {
|
|
|
205
209
|
export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
206
210
|
const parsed: ApiCliArgs = { endpoint: "" };
|
|
207
211
|
const positional: string[] = [];
|
|
212
|
+
const positionalIndexes: number[] = [];
|
|
208
213
|
const selectionFlags = emptySelectionFlags();
|
|
214
|
+
let listenFlag = false;
|
|
209
215
|
|
|
210
216
|
for (let index = 0; index < args.length; index += 1) {
|
|
211
217
|
const arg = args[index];
|
|
@@ -234,8 +240,10 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
|
234
240
|
parsed.raw = true;
|
|
235
241
|
break;
|
|
236
242
|
case "--stream":
|
|
243
|
+
parsed.stream = true;
|
|
244
|
+
break;
|
|
237
245
|
case "--listen":
|
|
238
|
-
|
|
246
|
+
listenFlag = true;
|
|
239
247
|
break;
|
|
240
248
|
case "--validate":
|
|
241
249
|
parsed.validate = true;
|
|
@@ -345,10 +353,18 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
|
345
353
|
throw unknownFlagError("api", arg, apiCommand.options);
|
|
346
354
|
}
|
|
347
355
|
positional.push(arg);
|
|
356
|
+
positionalIndexes.push(index);
|
|
348
357
|
break;
|
|
349
358
|
}
|
|
350
359
|
}
|
|
351
360
|
}
|
|
361
|
+
if (listenFlag) {
|
|
362
|
+
throw removedListenError(
|
|
363
|
+
positional.at(-1) ?? "",
|
|
364
|
+
"cli",
|
|
365
|
+
listenReplacement(args, positionalIndexes.at(-1), parsed.method),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
352
368
|
|
|
353
369
|
// api/retrieve positional boundary: the FINAL positional is the endpoint; every
|
|
354
370
|
// preceding positional is a fan-out target.
|
|
@@ -360,6 +376,79 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
|
360
376
|
return parsed;
|
|
361
377
|
}
|
|
362
378
|
|
|
379
|
+
/**
|
|
380
|
+
* The exact command `--listen` meant (#402): a GET becomes the menu's
|
|
381
|
+
* `/listen` endpoint, anything else keeps its command and streams. Secrets in
|
|
382
|
+
* the original arguments are not echoed back. Any value can carry one (a
|
|
383
|
+
* `-f script=` body holds whole commands), so every request value is a
|
|
384
|
+
* placeholder: `-f`/query values keep their key and operator, a `-d` body and
|
|
385
|
+
* passwords are replaced whole. Flag names, the method and the endpoint stay.
|
|
386
|
+
*/
|
|
387
|
+
function listenReplacement(
|
|
388
|
+
args: readonly string[],
|
|
389
|
+
endpointIndex: number | undefined,
|
|
390
|
+
method: string | undefined,
|
|
391
|
+
): string {
|
|
392
|
+
const get = (method ?? "GET").toUpperCase() === "GET";
|
|
393
|
+
const words: string[] = [];
|
|
394
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
395
|
+
const arg = args[index] as string;
|
|
396
|
+
if (arg === "--listen") {
|
|
397
|
+
if (!get) words.push("--stream");
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (index === endpointIndex && get && !normalizeApiEndpoint(arg).listen) {
|
|
401
|
+
words.push(shellWord(`${arg.replace(/\/+$/, "")}/listen`));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
words.push(shellWord(arg));
|
|
405
|
+
const value = args[index + 1];
|
|
406
|
+
const placeholder =
|
|
407
|
+
value === undefined ? undefined : redactedValue(arg, value);
|
|
408
|
+
if (placeholder !== undefined) {
|
|
409
|
+
words.push(placeholder);
|
|
410
|
+
index += 1;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return `centrs api ${words.join(" ")}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The placeholder that stands in for a request value, or `undefined` for a
|
|
418
|
+
* flag whose value is echoed (method, transport, bounds, names). Placeholders
|
|
419
|
+
* are left unquoted, like `<router>`.
|
|
420
|
+
*/
|
|
421
|
+
function redactedValue(flag: string, value: string): string | undefined {
|
|
422
|
+
switch (flag) {
|
|
423
|
+
case "--password":
|
|
424
|
+
case "--cdb-password":
|
|
425
|
+
return "<password>";
|
|
426
|
+
case "-d":
|
|
427
|
+
case "--data":
|
|
428
|
+
return "<json>";
|
|
429
|
+
case "-f":
|
|
430
|
+
case "--field":
|
|
431
|
+
case "--query":
|
|
432
|
+
case "--filter":
|
|
433
|
+
case "--raw-query": {
|
|
434
|
+
// Keep `key` plus its operator (`=`, `!=`, `<`, `>`); a bare word
|
|
435
|
+
// such as `#|` or `name` carries no value and is echoed.
|
|
436
|
+
let at = 0;
|
|
437
|
+
while (at < value.length && !"=<>".includes(value.charAt(at))) at += 1;
|
|
438
|
+
if (at === value.length) return shellWord(value);
|
|
439
|
+
return `${shellWord(value.slice(0, at + 1))}<value>`;
|
|
440
|
+
}
|
|
441
|
+
default:
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function shellWord(word: string): string {
|
|
447
|
+
return /^[\w@%+=:,./*-]+$/.test(word)
|
|
448
|
+
? word
|
|
449
|
+
: `'${word.replaceAll("'", "'\\''")}'`;
|
|
450
|
+
}
|
|
451
|
+
|
|
363
452
|
export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
364
453
|
let parsed: ApiCliArgs | undefined;
|
|
365
454
|
try {
|
|
@@ -404,7 +493,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
404
493
|
|
|
405
494
|
// Fan-out mode (selector flag present, or >1 positional target) has its own
|
|
406
495
|
// envelope shape, guards, and granular exit code. It runs first and rejects
|
|
407
|
-
// `--stream
|
|
496
|
+
// `--stream` + fan-out itself (single-session is exclusive).
|
|
408
497
|
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
409
498
|
return await runApiFanoutCli(
|
|
410
499
|
parsed,
|
|
@@ -419,22 +508,28 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
419
508
|
parsed.quickchr = selectionFlags.quickchr[0];
|
|
420
509
|
}
|
|
421
510
|
|
|
422
|
-
//
|
|
511
|
+
// A stream (`--stream`, or a `/listen` endpoint) consumes
|
|
423
512
|
// the NDJSON envelope stream instead of a single one-shot envelope.
|
|
424
513
|
const streaming =
|
|
425
|
-
parsed.
|
|
514
|
+
parsed.stream === true ||
|
|
426
515
|
(parsed.endpoint.length > 0 &&
|
|
427
516
|
normalizeApiEndpoint(parsed.endpoint).listen);
|
|
428
517
|
if (streaming) {
|
|
429
|
-
return await
|
|
518
|
+
return await runApiStreamCli(parsed, args);
|
|
430
519
|
}
|
|
431
520
|
|
|
432
|
-
|
|
521
|
+
// The `--host` flag stands in for a missing `<router>` (#428), with no CDB
|
|
522
|
+
// lookup; `CENTRS_HOST` never counts.
|
|
523
|
+
if (
|
|
524
|
+
!parsed.targetInput &&
|
|
525
|
+
parsed.quickchr === undefined &&
|
|
526
|
+
parsed.host === undefined
|
|
527
|
+
) {
|
|
433
528
|
throw missingTargetError({
|
|
434
529
|
command: "api",
|
|
435
530
|
summary: "`centrs api` requires a <router> and an <endpoint>.",
|
|
436
531
|
remediation:
|
|
437
|
-
"Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address
|
|
532
|
+
"Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`, or name the host with `--host <host|url>`.",
|
|
438
533
|
});
|
|
439
534
|
}
|
|
440
535
|
|
|
@@ -477,7 +572,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
477
572
|
verbose: parsed.verbose ?? false,
|
|
478
573
|
}),
|
|
479
574
|
);
|
|
480
|
-
} else if (format
|
|
575
|
+
} else if (format !== "text") {
|
|
481
576
|
const envelope = withTips(
|
|
482
577
|
buildApiErrorEnvelope(parsed ?? { endpoint: "" }, error),
|
|
483
578
|
tips,
|
|
@@ -526,14 +621,14 @@ async function runApiFanoutCli(
|
|
|
526
621
|
context: { flags: ["--raw", "fanout"] },
|
|
527
622
|
});
|
|
528
623
|
}
|
|
529
|
-
// `--
|
|
624
|
+
// `--stream` is single-session.
|
|
530
625
|
const listenRequested =
|
|
531
|
-
(parsed.
|
|
626
|
+
(parsed.stream ?? false) || endpointInfersListen(parsed.endpoint);
|
|
532
627
|
if (listenRequested) {
|
|
533
628
|
throw new CentrsError({
|
|
534
629
|
code: "usage/fanout-not-supported",
|
|
535
630
|
summary:
|
|
536
|
-
"`api --stream
|
|
631
|
+
"`api --stream` is single-session and cannot fan out across multiple targets.",
|
|
537
632
|
remediation:
|
|
538
633
|
"Follow a stream against a single router (no `--group`/`--where`/`--all`/`--default`/multiple positionals).",
|
|
539
634
|
context: { capability: "listen" },
|
|
@@ -573,7 +668,7 @@ async function runApiFanoutCli(
|
|
|
573
668
|
}
|
|
574
669
|
}
|
|
575
670
|
|
|
576
|
-
/** Whether an endpoint's trailing segment
|
|
671
|
+
/** Whether an endpoint's trailing `listen` segment implies `--stream` (without throwing). */
|
|
577
672
|
function endpointInfersListen(endpoint: string): boolean {
|
|
578
673
|
try {
|
|
579
674
|
return normalizeApiEndpoint(endpoint).listen;
|
|
@@ -583,13 +678,11 @@ function endpointInfersListen(endpoint: string): boolean {
|
|
|
583
678
|
}
|
|
584
679
|
|
|
585
680
|
/**
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
* Ctrl-C
|
|
589
|
-
* reflects whether the stream *started* cleanly — a failure before the first
|
|
590
|
-
* frame is `1`; a mid-stream error frame leaves a started stream at `0`.
|
|
681
|
+
* Emit all structured stream outcomes on stdout. Any failure sets exit 1,
|
|
682
|
+
* including a terminal error after successful rows. Raw mode retains its
|
|
683
|
+
* stderr error contract; Ctrl-C requests bounded cancellation.
|
|
591
684
|
*/
|
|
592
|
-
async function
|
|
685
|
+
async function runApiStreamCli(
|
|
593
686
|
parsed: ApiCliArgs,
|
|
594
687
|
args: readonly string[],
|
|
595
688
|
): Promise<number> {
|
|
@@ -598,26 +691,25 @@ async function runApiListenCli(
|
|
|
598
691
|
const onSigint = (): void => controller.abort();
|
|
599
692
|
process.on("SIGINT", onSigint);
|
|
600
693
|
let exitCode = 0;
|
|
601
|
-
let first = true;
|
|
602
694
|
try {
|
|
603
|
-
for await (const envelope of
|
|
695
|
+
for await (const envelope of apiStream(
|
|
604
696
|
parsed,
|
|
605
697
|
Bun.env,
|
|
606
698
|
controller.signal,
|
|
607
699
|
)) {
|
|
700
|
+
// `--raw` is bare payloads only; a notice has none.
|
|
701
|
+
if (parsed.raw && envelope.meta.operation?.stream?.kind === "notice")
|
|
702
|
+
continue;
|
|
608
703
|
const line = renderApiStreamLine(envelope, format, {
|
|
609
704
|
raw: parsed.raw,
|
|
610
705
|
verbose: parsed.verbose,
|
|
611
706
|
});
|
|
612
|
-
if (envelope.ok) {
|
|
707
|
+
if (envelope.ok || !parsed.raw) {
|
|
613
708
|
console.log(line);
|
|
614
709
|
} else {
|
|
615
710
|
console.error(line);
|
|
616
|
-
if (first) {
|
|
617
|
-
exitCode = 1;
|
|
618
|
-
}
|
|
619
711
|
}
|
|
620
|
-
|
|
712
|
+
if (!envelope.ok) exitCode = 1;
|
|
621
713
|
}
|
|
622
714
|
} finally {
|
|
623
715
|
process.off("SIGINT", onSigint);
|
package/src/cli/devices.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
describeCentrs,
|
|
23
23
|
devicesOutputFormats,
|
|
24
24
|
editInteractiveOnlyError,
|
|
25
|
+
initCdb,
|
|
25
26
|
listDevices,
|
|
26
27
|
listGroups,
|
|
27
28
|
loadCdb,
|
|
@@ -46,7 +47,8 @@ import {
|
|
|
46
47
|
|
|
47
48
|
export const devicesCommand: CliCommandMetadata = {
|
|
48
49
|
name: "devices",
|
|
49
|
-
usage:
|
|
50
|
+
usage:
|
|
51
|
+
"centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
50
52
|
summary:
|
|
51
53
|
"Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
|
|
52
54
|
options: [
|
|
@@ -197,6 +199,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
197
199
|
};
|
|
198
200
|
|
|
199
201
|
type DevicesSubcommand =
|
|
202
|
+
| "init"
|
|
200
203
|
| "list"
|
|
201
204
|
| "show"
|
|
202
205
|
| "groups"
|
|
@@ -378,6 +381,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
378
381
|
}
|
|
379
382
|
|
|
380
383
|
const subcommands: readonly DevicesSubcommand[] = [
|
|
384
|
+
"init",
|
|
381
385
|
"list",
|
|
382
386
|
"show",
|
|
383
387
|
"groups",
|
|
@@ -389,14 +393,14 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
389
393
|
const [rawSub, ...rest] = positional;
|
|
390
394
|
if (rawSub === undefined) {
|
|
391
395
|
throw new Error(
|
|
392
|
-
"`centrs devices` requires a subcommand: list, show, groups, add, edit, set, or remove.",
|
|
396
|
+
"`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
|
|
393
397
|
);
|
|
394
398
|
}
|
|
395
399
|
// Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
|
|
396
400
|
const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
|
|
397
401
|
if (!subcommands.includes(sub)) {
|
|
398
402
|
throw new Error(
|
|
399
|
-
`Unknown devices subcommand: ${rawSub}. Use list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
403
|
+
`Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
400
404
|
);
|
|
401
405
|
}
|
|
402
406
|
parsed.subcommand = sub;
|
|
@@ -606,6 +610,15 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
envSnapshot = Bun.env;
|
|
613
|
+
if (parsed.subcommand === "init") {
|
|
614
|
+
const envelope = await initCdb({
|
|
615
|
+
cdbFile: parsed.cdbFile,
|
|
616
|
+
cdbPassword: parsed.cdbPassword,
|
|
617
|
+
env: envSnapshot,
|
|
618
|
+
});
|
|
619
|
+
console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
609
622
|
const cdb = await loadCdb({
|
|
610
623
|
cdbFile: parsed.cdbFile,
|
|
611
624
|
cdbPassword: parsed.cdbPassword,
|
package/src/cli/execute.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
export const executeCommand: CliCommandMetadata = {
|
|
44
44
|
name: "execute",
|
|
45
45
|
usage:
|
|
46
|
-
"centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --group <name> <command> [flags]",
|
|
46
|
+
"centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --host <host|url> [--] <command> [flags] | centrs execute --group <name> <command> [flags]",
|
|
47
47
|
summary:
|
|
48
48
|
"Run a RouterOS read or write command via native API, REST, or mac-telnet.",
|
|
49
49
|
options: [
|
|
@@ -57,7 +57,8 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
57
57
|
{
|
|
58
58
|
flag: "--host",
|
|
59
59
|
valueName: "<host|url>",
|
|
60
|
-
description:
|
|
60
|
+
description:
|
|
61
|
+
"Host or base URL to dial. With no <target> it is the target (no CDB lookup); with one it overrides the resolved host.",
|
|
61
62
|
},
|
|
62
63
|
{
|
|
63
64
|
flag: "--port",
|
|
@@ -270,6 +271,10 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
|
270
271
|
// - `--` present: targets are the positionals BEFORE it, command is AFTER it.
|
|
271
272
|
// - selector flag (or `--quickchr`) present, no `--`: every positional is the
|
|
272
273
|
// command (targets come from the flag).
|
|
274
|
+
// - `--host` flag with a lone positional, no `--`: that positional is the
|
|
275
|
+
// command and `--host` stands in for `<router>` (#428). Two or more keep
|
|
276
|
+
// the legacy split, so `execute <mac> <cmd> --host <udp-endpoint>` (the
|
|
277
|
+
// mac-telnet delivery override) still parses. `CENTRS_HOST` never counts.
|
|
273
278
|
// - otherwise (no `--`, no selector): legacy single-target split — first
|
|
274
279
|
// positional is the target, the rest is the command. Multiple positional
|
|
275
280
|
// targets therefore REQUIRE `--`.
|
|
@@ -280,6 +285,9 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
|
280
285
|
} else if (hasSelector) {
|
|
281
286
|
parsed.targetPositionals = [];
|
|
282
287
|
parsed.command = positional.join(" ");
|
|
288
|
+
} else if (parsed.host !== undefined && positional.length === 1) {
|
|
289
|
+
parsed.targetPositionals = [];
|
|
290
|
+
parsed.command = positional[0] ?? "";
|
|
283
291
|
} else {
|
|
284
292
|
parsed.targetPositionals =
|
|
285
293
|
positional[0] !== undefined ? [positional[0]] : [];
|
|
@@ -331,13 +339,18 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
331
339
|
// No positional at all: lead with the missing-target guidance (CDB picker /
|
|
332
340
|
// discover) rather than the missing-command error — there is nothing to run
|
|
333
341
|
// without a device. A lone positional is treated as the target, so the
|
|
334
|
-
// "requires a command" path still fires for `execute <target>`.
|
|
335
|
-
|
|
342
|
+
// "requires a command" path still fires for `execute <target>`. The
|
|
343
|
+
// `--host` flag is a target of its own, with no CDB lookup.
|
|
344
|
+
if (
|
|
345
|
+
!parsed.targetInput &&
|
|
346
|
+
parsed.quickchr === undefined &&
|
|
347
|
+
parsed.host === undefined
|
|
348
|
+
) {
|
|
336
349
|
throw missingTargetError({
|
|
337
350
|
command: "execute",
|
|
338
351
|
summary: "`centrs execute` requires a <target> and a RouterOS command.",
|
|
339
352
|
remediation:
|
|
340
|
-
'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"
|
|
353
|
+
'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"`, or name the host with `--host <host|url>`.',
|
|
341
354
|
});
|
|
342
355
|
}
|
|
343
356
|
|