@tikoci/centrs 0.1.8 → 0.1.9-next.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +33 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/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
|
}
|
package/src/cli/common.ts
CHANGED
|
@@ -6,7 +6,51 @@
|
|
|
6
6
|
* together.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import type { Tip, Warning } from "../core/envelope.ts";
|
|
10
|
+
import { CentrsError, terminalSafeLine } from "../errors.ts";
|
|
11
|
+
import { redactTargetInput } from "../resolver/target.ts";
|
|
12
|
+
import { formatTipsText } from "./missing-target.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Warnings that a single-target text-mode CLI leaves out (#470).
|
|
16
|
+
* `transport/provider-trust` rides every quickchr run over TLS or SSH, so
|
|
17
|
+
* printing it would be noise. `--json` keeps every warning.
|
|
18
|
+
*/
|
|
19
|
+
const TEXT_QUIET_WARNING_CODES: readonly string[] = [
|
|
20
|
+
"transport/provider-trust",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One `warning [<code>]: <message>` line per envelope warning, in the same form
|
|
25
|
+
* as fan-out text, minus {@link TEXT_QUIET_WARNING_CODES}.
|
|
26
|
+
*/
|
|
27
|
+
export function textWarningLines(warnings: readonly Warning[]): string[] {
|
|
28
|
+
return warnings
|
|
29
|
+
.filter((warning) => !TEXT_QUIET_WARNING_CODES.includes(warning.code))
|
|
30
|
+
.map((warning) =>
|
|
31
|
+
terminalSafeLine(`warning [${warning.code}]: ${warning.message}`),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* #470: in text mode a single-target command's stdout is its data and nothing
|
|
37
|
+
* else. The notes around the data go to stderr: the `--verbose` header before
|
|
38
|
+
* it ({@link printTextHeader}), then warnings and tips after it. This applies
|
|
39
|
+
* on success and on failure, so a write whose outcome is unknown still says so.
|
|
40
|
+
*/
|
|
41
|
+
export function printTextNotes(envelope: {
|
|
42
|
+
warnings: readonly Warning[];
|
|
43
|
+
tips: readonly Tip[];
|
|
44
|
+
}): void {
|
|
45
|
+
for (const line of textWarningLines(envelope.warnings)) console.error(line);
|
|
46
|
+
const tips = formatTipsText(envelope.tips);
|
|
47
|
+
if (tips) console.error(tips);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The `--verbose` header of a text-mode result, on stderr (#470). */
|
|
51
|
+
export function printTextHeader(lines: readonly string[]): void {
|
|
52
|
+
if (lines.length > 0) console.error(lines.join("\n"));
|
|
53
|
+
}
|
|
10
54
|
|
|
11
55
|
export interface CliCommandOption {
|
|
12
56
|
flag: string;
|
|
@@ -106,6 +150,141 @@ export function unknownFlagError(
|
|
|
106
150
|
});
|
|
107
151
|
}
|
|
108
152
|
|
|
153
|
+
/** Single-quote a token for a shell line a user can copy, unless it is plain. */
|
|
154
|
+
function shellQuote(token: string): string {
|
|
155
|
+
return /^[\w/:=.,@%+-]+$/.test(token)
|
|
156
|
+
? token
|
|
157
|
+
: `'${token.replaceAll("'", `'\\''`)}'`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A centrs flag written after a bare `--` (#459).
|
|
162
|
+
*
|
|
163
|
+
* `--` makes every later token RouterOS input, so a trailing `--json` (the
|
|
164
|
+
* habitual position: `execute r1 -- '/cmd' --json`) went to the router and
|
|
165
|
+
* failed there as `unknown-attribute`. The RouterOS command is one quoted
|
|
166
|
+
* argument in every documented form, so a later token that is exactly one of
|
|
167
|
+
* this command's LONG flags (or its `--flag=value` spelling) is this mistake.
|
|
168
|
+
* Short aliases (`-u`, `-h`) are left alone: a lone `-x` is plausible
|
|
169
|
+
* RouterOS input, `--json` is not. A command that really needs such a token
|
|
170
|
+
* can carry it inside the quoted command.
|
|
171
|
+
*
|
|
172
|
+
* Throws `usage/flag-after-separator` with the command line rewritten with the
|
|
173
|
+
* flags (and their values) moved before `--`. Password values are never
|
|
174
|
+
* echoed. A no-op when there is no `--`.
|
|
175
|
+
*/
|
|
176
|
+
export function rejectFlagsAfterSeparator(
|
|
177
|
+
command: string,
|
|
178
|
+
args: readonly string[],
|
|
179
|
+
options: readonly CliCommandOption[],
|
|
180
|
+
): void {
|
|
181
|
+
const optionByFlag = new Map<string, CliCommandOption>();
|
|
182
|
+
for (const option of options) {
|
|
183
|
+
for (const token of option.flag.split("/")) {
|
|
184
|
+
const trimmed = token.trim();
|
|
185
|
+
if (trimmed.startsWith("-") && trimmed !== "--") {
|
|
186
|
+
optionByFlag.set(trimmed, option);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let separator = -1;
|
|
191
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
192
|
+
const token = args[index] ?? "";
|
|
193
|
+
if (token === "--") {
|
|
194
|
+
separator = index;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
// A flag's value may itself be `--`; only the parser's separator counts.
|
|
198
|
+
if (optionByFlag.get(token)?.valueName !== undefined) index += 1;
|
|
199
|
+
}
|
|
200
|
+
if (separator === -1) return;
|
|
201
|
+
const secret = (flag: string) =>
|
|
202
|
+
flag.startsWith("-") && flag.includes("password");
|
|
203
|
+
const before: string[] = [];
|
|
204
|
+
for (let index = 0; index < separator; index += 1) {
|
|
205
|
+
const token = args[index] ?? "";
|
|
206
|
+
const [name = ""] = token.split("=");
|
|
207
|
+
const equal = token.indexOf("=");
|
|
208
|
+
if (equal >= 0 && optionByFlag.get(name)?.valueName !== undefined) {
|
|
209
|
+
before.push(
|
|
210
|
+
name,
|
|
211
|
+
secret(name) ? "<password>" : redactTargetInput(token.slice(equal + 1)),
|
|
212
|
+
);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (equal >= 0 && secret(name)) {
|
|
216
|
+
before.push(`${name}=<password>`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
before.push(redactTargetInput(token));
|
|
220
|
+
if (optionByFlag.get(name)?.valueName !== undefined || secret(name)) {
|
|
221
|
+
const value = args[index + 1];
|
|
222
|
+
if (index + 1 < separator && value !== undefined) {
|
|
223
|
+
before.push(secret(name) ? "<password>" : redactTargetInput(value));
|
|
224
|
+
index += 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const unsupportedInlineBooleans: string[] = [];
|
|
229
|
+
const moved: string[] = [];
|
|
230
|
+
const kept: string[] = [];
|
|
231
|
+
const flags: string[] = [];
|
|
232
|
+
const after = args.slice(separator + 1);
|
|
233
|
+
for (let index = 0; index < after.length; index += 1) {
|
|
234
|
+
const token = after[index] ?? "";
|
|
235
|
+
const [name = "", ...valueParts] = token.split("=");
|
|
236
|
+
const option = name.startsWith("--") ? optionByFlag.get(name) : undefined;
|
|
237
|
+
if (option === undefined) {
|
|
238
|
+
kept.push(token);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
flags.push(name);
|
|
242
|
+
// The parsers take a value flag's value as the next token (`--timeout 30s`,
|
|
243
|
+
// never `--timeout=30s`), so the fixed line splits it; only a boolean's
|
|
244
|
+
// supported `=` form (`--validate=no`) is kept as typed.
|
|
245
|
+
if (valueParts.length > 0) {
|
|
246
|
+
if (option.valueName === undefined) {
|
|
247
|
+
if (command !== "execute" || name !== "--validate") {
|
|
248
|
+
unsupportedInlineBooleans.push(name);
|
|
249
|
+
}
|
|
250
|
+
moved.push(token);
|
|
251
|
+
} else {
|
|
252
|
+
moved.push(name, secret(name) ? "<password>" : valueParts.join("="));
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
moved.push(name);
|
|
257
|
+
const value = after[index + 1];
|
|
258
|
+
if (option.valueName !== undefined && value !== undefined) {
|
|
259
|
+
moved.push(secret(name) ? "<password>" : value);
|
|
260
|
+
index += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (flags.length === 0) return;
|
|
264
|
+
// A moved --host value needs the same redaction as a positional target.
|
|
265
|
+
// Keep the RouterOS command itself verbatim; it is not a target.
|
|
266
|
+
const fixed = [
|
|
267
|
+
"centrs",
|
|
268
|
+
command,
|
|
269
|
+
...before,
|
|
270
|
+
...moved.map((token) => redactTargetInput(token)),
|
|
271
|
+
"--",
|
|
272
|
+
...kept,
|
|
273
|
+
]
|
|
274
|
+
.map(shellQuote)
|
|
275
|
+
.join(" ");
|
|
276
|
+
const list = flags.map((flag) => `\`${flag}\``).join(", ");
|
|
277
|
+
throw new CentrsError({
|
|
278
|
+
code: "usage/flag-after-separator",
|
|
279
|
+
summary: `${list} ${flags.length === 1 ? "comes" : "come"} after \`--\`, so ${flags.length === 1 ? "it" : "they"} would be sent to RouterOS as part of the command.`,
|
|
280
|
+
remediation:
|
|
281
|
+
unsupportedInlineBooleans.length > 0
|
|
282
|
+
? `Put centrs flags before \`--\`. These flags accept no value: ${unsupportedInlineBooleans.map((flag) => `\`${flag}\``).join(", ")}. Use a bare flag to enable it, or omit it to keep the default.`
|
|
283
|
+
: `Put centrs flags before \`--\`: \`${fixed}\``,
|
|
284
|
+
context: { flags },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
109
288
|
export function renderCommandHelp(
|
|
110
289
|
describe: string,
|
|
111
290
|
command: CliCommandMetadata,
|
package/src/cli/devices.ts
CHANGED
|
@@ -746,10 +746,8 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
746
746
|
centrsError,
|
|
747
747
|
);
|
|
748
748
|
if (format === "json" || format === "yaml") {
|
|
749
|
-
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
750
749
|
console.error(renderDevicesEnvelope(envelope, format));
|
|
751
750
|
} else {
|
|
752
|
-
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
753
751
|
console.error(formatCentrsErrorText(centrsError));
|
|
754
752
|
}
|
|
755
753
|
return 1;
|
package/src/cli/discover.ts
CHANGED
|
@@ -207,7 +207,6 @@ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
|
|
|
207
207
|
remediation:
|
|
208
208
|
"Use `centrs discover --help` to inspect the supported flags.",
|
|
209
209
|
});
|
|
210
|
-
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
211
210
|
console.error(formatCentrsErrorText(centrsError));
|
|
212
211
|
return 1;
|
|
213
212
|
}
|
package/src/cli/execute.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { fanoutExitCode } from "../core/fanout.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
asCentrsError,
|
|
4
|
+
CentrsError,
|
|
5
|
+
formatCentrsErrorText,
|
|
6
|
+
} from "../errors.ts";
|
|
3
7
|
import {
|
|
4
8
|
buildExecuteErrorEnvelope,
|
|
5
9
|
type ExecuteOutputFormat,
|
|
6
10
|
type ExecuteRequest,
|
|
7
11
|
executeEnvelope,
|
|
8
12
|
executeOutputFormats,
|
|
13
|
+
executeVerboseLines,
|
|
9
14
|
renderExecuteEnvelope,
|
|
10
15
|
} from "../execute.ts";
|
|
16
|
+
import {
|
|
17
|
+
buildExecuteCmrErrorEnvelope,
|
|
18
|
+
executeCmr,
|
|
19
|
+
renderExecuteCmrEnvelope,
|
|
20
|
+
} from "../execute-cmr.ts";
|
|
11
21
|
import {
|
|
12
22
|
buildExecuteFanoutErrorEnvelope,
|
|
13
23
|
executeFanout,
|
|
@@ -18,6 +28,9 @@ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
|
|
|
18
28
|
import {
|
|
19
29
|
type CliCommandMetadata,
|
|
20
30
|
expectValue,
|
|
31
|
+
printTextHeader,
|
|
32
|
+
printTextNotes,
|
|
33
|
+
rejectFlagsAfterSeparator,
|
|
21
34
|
renderCommandHelp,
|
|
22
35
|
unknownFlagError,
|
|
23
36
|
} from "./common.ts";
|
|
@@ -54,6 +67,17 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
54
67
|
"Pin the protocol selector; no silent downgrade when set. A bare MAC target defaults to mac-telnet.",
|
|
55
68
|
},
|
|
56
69
|
...selectionCommandOptions,
|
|
70
|
+
{
|
|
71
|
+
flag: "--cmr",
|
|
72
|
+
description:
|
|
73
|
+
"Run on CMR clients through their controller: the <target> is the controller (RouterOS 7.26beta1+, `cmr` package). Needs `--labels`; native-api or rest-api only.",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
flag: "--labels",
|
|
77
|
+
valueName: "<expr>",
|
|
78
|
+
description:
|
|
79
|
+
"With `--cmr`: RouterOS label expression selecting clients (comma list; plain = OR, `+` AND, `-` AND NOT, `all`).",
|
|
80
|
+
},
|
|
57
81
|
{
|
|
58
82
|
flag: "--host",
|
|
59
83
|
valueName: "<host|url>",
|
|
@@ -135,24 +159,27 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
135
159
|
},
|
|
136
160
|
{
|
|
137
161
|
flag: "--verbose",
|
|
138
|
-
description: "Include additional context in text output.",
|
|
162
|
+
description: "Include additional context in text output, on stderr.",
|
|
139
163
|
},
|
|
140
164
|
{
|
|
141
165
|
flag: "--",
|
|
142
166
|
description:
|
|
143
|
-
"End centrs option parsing: every following token is the literal RouterOS command
|
|
167
|
+
"End centrs option parsing: every following token is the literal RouterOS command (e.g. `-- /interface print where disabled=yes`). Put centrs flags before it; one of execute's own long flags after it is refused as `usage/flag-after-separator`.",
|
|
144
168
|
},
|
|
145
169
|
],
|
|
146
170
|
};
|
|
147
171
|
|
|
148
172
|
interface ExecuteCliArgs extends ExecuteRequest {
|
|
149
173
|
help?: boolean;
|
|
174
|
+
cmr?: boolean;
|
|
175
|
+
labels?: string;
|
|
150
176
|
format?: ExecuteOutputFormat;
|
|
151
177
|
selectionFlags?: SelectionFlags;
|
|
152
178
|
targetPositionals?: readonly string[];
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
182
|
+
rejectFlagsAfterSeparator("execute", args, executeCommand.options);
|
|
156
183
|
const parsed: ExecuteCliArgs = { command: "" };
|
|
157
184
|
const selectionFlags = emptySelectionFlags();
|
|
158
185
|
const positional: string[] = [];
|
|
@@ -194,6 +221,12 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
|
194
221
|
case "--yes":
|
|
195
222
|
parsed.yes = true;
|
|
196
223
|
break;
|
|
224
|
+
case "--cmr":
|
|
225
|
+
parsed.cmr = true;
|
|
226
|
+
break;
|
|
227
|
+
case "--labels":
|
|
228
|
+
parsed.labels = expectValue(args, ++index, arg);
|
|
229
|
+
break;
|
|
197
230
|
case "--validate":
|
|
198
231
|
parsed.validate = true;
|
|
199
232
|
break;
|
|
@@ -322,6 +355,14 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
322
355
|
selectionFlags.quickchr[0] ?? "",
|
|
323
356
|
);
|
|
324
357
|
}
|
|
358
|
+
if (parsed.cmr || parsed.labels !== undefined) {
|
|
359
|
+
return await runExecuteCmrCli(
|
|
360
|
+
parsed,
|
|
361
|
+
selectionFlags,
|
|
362
|
+
targetPositionals,
|
|
363
|
+
args,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
325
366
|
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
326
367
|
return await runExecuteFanoutCli(
|
|
327
368
|
parsed,
|
|
@@ -363,15 +404,20 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
363
404
|
)?.request?.format ??
|
|
364
405
|
parsed.format ??
|
|
365
406
|
"text";
|
|
407
|
+
const text = resolvedFormat === "text";
|
|
408
|
+
if (text && parsed.verbose) {
|
|
409
|
+
printTextHeader(executeVerboseLines(envelope));
|
|
410
|
+
}
|
|
366
411
|
const rendered = renderExecuteEnvelope(envelope, resolvedFormat, {
|
|
367
412
|
verbose: parsed.verbose,
|
|
368
413
|
});
|
|
369
414
|
if (envelope.ok) {
|
|
370
415
|
console.log(rendered);
|
|
371
|
-
|
|
416
|
+
} else {
|
|
417
|
+
console.error(rendered);
|
|
372
418
|
}
|
|
373
|
-
|
|
374
|
-
return 1;
|
|
419
|
+
if (text) printTextNotes(envelope);
|
|
420
|
+
return envelope.ok ? 0 : 1;
|
|
375
421
|
} catch (error) {
|
|
376
422
|
// Parse/usage errors land here too: surface them through the same typed
|
|
377
423
|
// envelope as every other runner (no raw message, no exit code 2).
|
|
@@ -388,14 +434,12 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
388
434
|
tips,
|
|
389
435
|
);
|
|
390
436
|
console.error(
|
|
391
|
-
// codeql[js/clear-text-logging] No secret reaches this sink. The tainted 'password' flows in only as provenance (SettingSource={kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
392
437
|
renderExecuteEnvelope(envelope, format, {
|
|
393
438
|
verbose: parsed?.verbose ?? false,
|
|
394
439
|
}),
|
|
395
440
|
);
|
|
396
441
|
} else {
|
|
397
442
|
console.error(
|
|
398
|
-
// codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
|
|
399
443
|
formatCentrsErrorText(
|
|
400
444
|
asCentrsError(error, {
|
|
401
445
|
code: "input/invalid-command",
|
|
@@ -441,7 +485,6 @@ async function runExecuteFanoutCli(
|
|
|
441
485
|
} catch (error) {
|
|
442
486
|
const envelope = buildExecuteFanoutErrorEnvelope(parsed, error);
|
|
443
487
|
console.error(
|
|
444
|
-
// codeql[js/clear-text-logging] Twin of #80/#81/#83/#84. No secret logged: buildExecuteFanoutErrorEnvelope meta is {target:{},via,settings:{}}; CodeQL taints the whole request object but the builder projects the password away.
|
|
445
488
|
renderExecuteFanoutEnvelope(envelope, format, {
|
|
446
489
|
verbose: parsed.verbose ?? false,
|
|
447
490
|
}),
|
|
@@ -450,6 +493,107 @@ async function runExecuteFanoutCli(
|
|
|
450
493
|
}
|
|
451
494
|
}
|
|
452
495
|
|
|
496
|
+
/**
|
|
497
|
+
* `execute --cmr`: the single target is the CMR controller; `--labels` picks
|
|
498
|
+
* the clients. Fan-out selectors would mix two selection sources, so they are
|
|
499
|
+
* refused rather than combined (#450).
|
|
500
|
+
*/
|
|
501
|
+
async function runExecuteCmrCli(
|
|
502
|
+
parsed: ExecuteCliArgs,
|
|
503
|
+
selectionFlags: SelectionFlags,
|
|
504
|
+
targetPositionals: readonly string[],
|
|
505
|
+
args: readonly string[],
|
|
506
|
+
): Promise<number> {
|
|
507
|
+
const format = inferExecuteFormat(args, parsed);
|
|
508
|
+
const controller = new AbortController();
|
|
509
|
+
// First Ctrl-C cancels (unsent members become `cmr/cancelled`); a second
|
|
510
|
+
// one exits at once in case a controller call does not end promptly.
|
|
511
|
+
const onSigint = (): void => {
|
|
512
|
+
if (controller.signal.aborted) process.exit(130);
|
|
513
|
+
controller.abort();
|
|
514
|
+
};
|
|
515
|
+
process.on("SIGINT", onSigint);
|
|
516
|
+
try {
|
|
517
|
+
if (!parsed.cmr) {
|
|
518
|
+
throw new CentrsError({
|
|
519
|
+
code: "usage/conflicting-flags",
|
|
520
|
+
summary:
|
|
521
|
+
"`--labels` selects CMR clients and only applies with `--cmr`.",
|
|
522
|
+
remediation:
|
|
523
|
+
"Add `--cmr` and name the controller as the target, or drop `--labels`.",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (
|
|
527
|
+
hasTargetSelector({ ...selectionFlags, quickchr: [] }) ||
|
|
528
|
+
selectionFlags.quickchr.length > 1 ||
|
|
529
|
+
targetPositionals.length > 1
|
|
530
|
+
) {
|
|
531
|
+
throw new CentrsError({
|
|
532
|
+
code: "usage/conflicting-flags",
|
|
533
|
+
summary:
|
|
534
|
+
"`--cmr` takes one controller target; CDB fan-out selectors and several targets cannot be combined with it.",
|
|
535
|
+
remediation:
|
|
536
|
+
"Name one controller (a positional, `--host` or one `--quickchr`) and pick clients with `--labels`.",
|
|
537
|
+
context: {
|
|
538
|
+
positionals: targetPositionals.length,
|
|
539
|
+
quickchr: selectionFlags.quickchr.length,
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if (selectionFlags.quickchr.length === 1) {
|
|
544
|
+
parsed.quickchr = selectionFlags.quickchr[0];
|
|
545
|
+
}
|
|
546
|
+
if (
|
|
547
|
+
!parsed.targetInput &&
|
|
548
|
+
parsed.quickchr === undefined &&
|
|
549
|
+
parsed.host === undefined
|
|
550
|
+
) {
|
|
551
|
+
throw missingTargetError({
|
|
552
|
+
command: "execute",
|
|
553
|
+
summary:
|
|
554
|
+
"`centrs execute --cmr` requires the CMR controller as its <target>.",
|
|
555
|
+
remediation:
|
|
556
|
+
'Name the controller, e.g. `centrs execute ctl --cmr --labels all -- ":put ok"`.',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
const envelope = await executeCmr({
|
|
560
|
+
...parsed,
|
|
561
|
+
labels: parsed.labels ?? "",
|
|
562
|
+
concurrency: selectionFlags.concurrency,
|
|
563
|
+
signal: controller.signal,
|
|
564
|
+
});
|
|
565
|
+
const resolvedFormat = envelope.meta.operation?.request.format ?? format;
|
|
566
|
+
console.log(
|
|
567
|
+
renderExecuteCmrEnvelope(envelope, resolvedFormat, {
|
|
568
|
+
verbose: parsed.verbose ?? false,
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
return fanoutExitCode(envelope);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
console.error(
|
|
574
|
+
renderExecuteCmrEnvelope(
|
|
575
|
+
buildExecuteCmrErrorEnvelope(
|
|
576
|
+
{ targetInput: parsed.targetInput, via: parsed.via },
|
|
577
|
+
error,
|
|
578
|
+
),
|
|
579
|
+
format,
|
|
580
|
+
{
|
|
581
|
+
verbose: parsed.verbose ?? false,
|
|
582
|
+
},
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
return 1;
|
|
586
|
+
} finally {
|
|
587
|
+
process.off("SIGINT", onSigint);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The output format for an error that may have struck before parsing finished.
|
|
593
|
+
* Reads the argv the way the parser does: only tokens before `--` (after it is
|
|
594
|
+
* RouterOS input), last format flag wins, and a value-taking flag's value is
|
|
595
|
+
* skipped, so `--format yaml -- '/x' --json` renders YAML.
|
|
596
|
+
*/
|
|
453
597
|
function inferExecuteFormat(
|
|
454
598
|
args: readonly string[],
|
|
455
599
|
parsed?: ExecuteCliArgs,
|
|
@@ -457,22 +601,33 @@ function inferExecuteFormat(
|
|
|
457
601
|
if (parsed?.format) {
|
|
458
602
|
return parsed.format;
|
|
459
603
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (
|
|
467
|
-
value
|
|
468
|
-
executeOutputFormats.includes(value as ExecuteOutputFormat)
|
|
469
|
-
|
|
470
|
-
|
|
604
|
+
let format: ExecuteOutputFormat = "text";
|
|
605
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
606
|
+
const arg = args[index];
|
|
607
|
+
if (arg === undefined || arg === "--") break;
|
|
608
|
+
if (arg === "--json") {
|
|
609
|
+
format = "json";
|
|
610
|
+
} else if (arg === "--format") {
|
|
611
|
+
const value = args[index + 1];
|
|
612
|
+
if (executeOutputFormats.includes(value as ExecuteOutputFormat)) {
|
|
613
|
+
format = value as ExecuteOutputFormat;
|
|
614
|
+
}
|
|
615
|
+
index += 1;
|
|
616
|
+
} else if (EXECUTE_VALUE_FLAGS.has(arg)) {
|
|
617
|
+
index += 1;
|
|
471
618
|
}
|
|
472
619
|
}
|
|
473
|
-
return
|
|
620
|
+
return format;
|
|
474
621
|
}
|
|
475
622
|
|
|
623
|
+
const EXECUTE_VALUE_FLAGS: ReadonlySet<string> = new Set(
|
|
624
|
+
executeCommand.options.flatMap((option) =>
|
|
625
|
+
option.valueName === undefined
|
|
626
|
+
? []
|
|
627
|
+
: option.flag.split("/").map((token) => token.trim()),
|
|
628
|
+
),
|
|
629
|
+
);
|
|
630
|
+
|
|
476
631
|
function parseIntegerFlag(value: string, flag: string): number {
|
|
477
632
|
const parsed = Number.parseInt(value, 10);
|
|
478
633
|
if (!Number.isInteger(parsed)) {
|
package/src/cli/explain.ts
CHANGED
|
@@ -51,6 +51,7 @@ import type { ResolvedSetting } from "../resolver/settings.ts";
|
|
|
51
51
|
import {
|
|
52
52
|
type CliCommandMetadata,
|
|
53
53
|
expectValue,
|
|
54
|
+
rejectFlagsAfterSeparator,
|
|
54
55
|
renderCommandHelp,
|
|
55
56
|
unknownFlagError,
|
|
56
57
|
} from "./common.ts";
|
|
@@ -118,6 +119,7 @@ export interface ExplainCliArgs {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
122
|
+
rejectFlagsAfterSeparator("explain", args, explainCliCommand.options);
|
|
121
123
|
const parsed: ExplainCliArgs = {
|
|
122
124
|
positionals: [],
|
|
123
125
|
failOn: "error",
|
package/src/cli/mcp.ts
CHANGED
|
@@ -101,7 +101,6 @@ export async function runMcpCli(args: readonly string[]): Promise<number> {
|
|
|
101
101
|
return 0;
|
|
102
102
|
} catch (error) {
|
|
103
103
|
console.error(
|
|
104
|
-
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
105
104
|
formatCentrsErrorText(
|
|
106
105
|
asCentrsError(error, {
|
|
107
106
|
code: "internal/mcp-start",
|