@tikoci/centrs 0.1.9-next.27 → 0.1.9
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 +64 -6
- package/README.md +15 -13
- package/commands/devices/README.md +89 -28
- package/commands/devices/examples.md +112 -1
- package/commands/execute/README.md +2 -2
- package/commands/retrieve/README.md +2 -2
- package/commands/settings/README.md +6 -0
- package/commands/settings/examples.md +17 -0
- package/docs/CLI.md +43 -9
- package/docs/errors/input/empty-stdin.md +27 -0
- package/docs/errors/input/invalid-stdin.md +28 -0
- package/docs/errors/usage/stdin-is-tty.md +21 -0
- package/package.json +4 -4
- package/src/cli/api.ts +1 -1
- package/src/cli/common.ts +1 -0
- package/src/cli/devices.ts +189 -24
- package/src/cli/execute.ts +3 -3
- package/src/cli/settings.ts +28 -6
- package/src/core/error-catalog.ts +15 -0
- package/src/devices.ts +224 -24
- package/src/index.ts +1 -0
- package/src/mcp/safety.ts +7 -6
- package/src/mcp/tools.ts +30 -68
- package/src/resolver/cdb.ts +9 -8
- package/src/resolver/selection.ts +4 -4
- package/src/settings.ts +23 -8
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# `input/empty-stdin`
|
|
2
|
+
|
|
3
|
+
A flag that reads its value from stdin (e.g. `--password-stdin`) received
|
|
4
|
+
nothing.
|
|
5
|
+
|
|
6
|
+
`centrs devices add`/`set --password-stdin` strips one trailing newline (`\n`
|
|
7
|
+
or `\r\n`) and refuses what is left if it is empty. An empty pipe is usually a
|
|
8
|
+
mistake — an unset variable, a missing file, a command that printed nothing —
|
|
9
|
+
so it is not treated as "clear the password".
|
|
10
|
+
|
|
11
|
+
## Fix
|
|
12
|
+
|
|
13
|
+
Pipe a non-empty value:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
printf '%s' "$ROUTER_PW" | centrs devices set 192.0.2.5 --password-stdin
|
|
17
|
+
centrs devices set 192.0.2.5 --password-stdin < pw.txt
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
To clear a stored password on purpose, say so with the argv flag:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
centrs devices set 192.0.2.5 --password ""
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
See [`commands/devices/README.md`](https://github.com/tikoci/centrs/blob/main/commands/devices/README.md)
|
|
27
|
+
for the record-password channels.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# `input/invalid-stdin`
|
|
2
|
+
|
|
3
|
+
A flag that reads its value from stdin (e.g. `--password-stdin`) received
|
|
4
|
+
bytes that are not valid UTF-8 text, or began with a byte-order mark.
|
|
5
|
+
|
|
6
|
+
`centrs devices add`/`set --password-stdin` strips one trailing newline (`\n`
|
|
7
|
+
or `\r\n`) and decodes the rest as UTF-8. The CDB stores record strings as
|
|
8
|
+
UTF-8 and drops a leading byte-order mark (U+FEFF) when it reads them back, so
|
|
9
|
+
malformed bytes or a BOM would store a credential different from what was
|
|
10
|
+
piped in. centrs refuses instead of guessing. The bytes are never echoed.
|
|
11
|
+
|
|
12
|
+
## Fix
|
|
13
|
+
|
|
14
|
+
Pipe the password as plain UTF-8 text:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
printf '%s' "$ROUTER_PW" | centrs devices set 192.0.2.5 --password-stdin
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If the value comes from a file an editor saved with a BOM (common on Windows),
|
|
21
|
+
re-save it as UTF-8 without BOM, or strip the first three bytes:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
tail -c +4 pw.txt | centrs devices set 192.0.2.5 --password-stdin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
See [`commands/devices/README.md`](https://github.com/tikoci/centrs/blob/main/commands/devices/README.md)
|
|
28
|
+
for the record-password channels.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# `usage/stdin-is-tty`
|
|
2
|
+
|
|
3
|
+
A flag that reads its value from stdin (e.g. `--password-stdin`) was given
|
|
4
|
+
while stdin is a terminal.
|
|
5
|
+
|
|
6
|
+
`--password-stdin` exists for automation: the value arrives on fd 0 so it never
|
|
7
|
+
appears in argv or `ps`. centrs does not prompt for it at a terminal — waiting
|
|
8
|
+
on a keyboard with no prompt would look like a hang, and echoing a typed secret
|
|
9
|
+
would defeat the point. Interactive prompting is tracked separately.
|
|
10
|
+
|
|
11
|
+
## Fix
|
|
12
|
+
|
|
13
|
+
Pipe or redirect the value:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
printf '%s' "$ROUTER_PW" | centrs devices add 192.0.2.5 --user admin --password-stdin
|
|
17
|
+
centrs devices add 192.0.2.5 --user admin --password-stdin < pw.txt
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
See [`commands/devices/README.md`](https://github.com/tikoci/centrs/blob/main/commands/devices/README.md)
|
|
21
|
+
for the record-password channels.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/centrs",
|
|
3
|
-
"version": "0.1.9
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"repository": {
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/tikoci/centrs#readme",
|
|
11
11
|
"bugs": "https://github.com/tikoci/centrs/issues",
|
|
12
|
-
"packageManager": "bun@1.4.
|
|
12
|
+
"packageManager": "bun@1.4.4",
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@biomejs/biome": "2.6.3",
|
|
16
16
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
|
|
17
|
-
"@types/bun": "1.4.
|
|
17
|
+
"@types/bun": "1.4.4",
|
|
18
18
|
"cloc": "^2.6.0-cloc",
|
|
19
19
|
"cspell": "^10.3.6",
|
|
20
20
|
"has-flag": "4.0.0",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"description": "All-in-one TypeScript library/CLI/MCP for interacting with MikroTik RouterOS devices (TUI stub)",
|
|
44
44
|
"engines": {
|
|
45
|
-
"bun": ">=1.
|
|
45
|
+
"bun": ">=1.4.4 <2"
|
|
46
46
|
},
|
|
47
47
|
"private": false,
|
|
48
48
|
"files": [
|
package/src/cli/api.ts
CHANGED
|
@@ -188,7 +188,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
188
188
|
{
|
|
189
189
|
flag: "--validate / --no-validate",
|
|
190
190
|
description:
|
|
191
|
-
"
|
|
191
|
+
"Validate structured requests with `/console/inspect`; `/execute` scripts run offline analysis, then RouterOS :parse (default true; `--validate=false` disables both stages).",
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
flag: "--format",
|
package/src/cli/common.ts
CHANGED
package/src/cli/devices.ts
CHANGED
|
@@ -46,7 +46,23 @@ import {
|
|
|
46
46
|
unknownFlagError,
|
|
47
47
|
} from "./common.ts";
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Flags `init`/`encrypt` honor. They touch only the CDB file, never a record,
|
|
51
|
+
* so record, selection, and provenance flags would be silently ignored there.
|
|
52
|
+
*/
|
|
53
|
+
const CDB_FILE_FLAGS: Readonly<Record<"init" | "encrypt", readonly string[]>> =
|
|
54
|
+
{
|
|
55
|
+
init: ["--cdb-file", "--cdb-password", "--format", "--json", "--encrypt"],
|
|
56
|
+
encrypt: [
|
|
57
|
+
"--cdb-file",
|
|
58
|
+
"--cdb-password",
|
|
59
|
+
"--format",
|
|
60
|
+
"--json",
|
|
61
|
+
"--no-backup",
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const devicesMetadata: CliCommandMetadata = {
|
|
50
66
|
name: "devices",
|
|
51
67
|
usage:
|
|
52
68
|
"centrs devices <init|encrypt|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
@@ -73,7 +89,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
73
89
|
flag: "--cdb-password",
|
|
74
90
|
valueName: "<secret>",
|
|
75
91
|
description:
|
|
76
|
-
"
|
|
92
|
+
"CDB password for encrypted reads, writes, explicit encrypted initialization and conversion. Falls back to `CENTRS_CDB_PASSWORD`; a password alone never converts an open CDB.",
|
|
77
93
|
},
|
|
78
94
|
{
|
|
79
95
|
flag: "--via",
|
|
@@ -85,7 +101,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
85
101
|
flag: "--group",
|
|
86
102
|
valueName: "<name>",
|
|
87
103
|
description:
|
|
88
|
-
"`list` filter / `add`,`
|
|
104
|
+
"`list` filter / `add`,`set` first-class group field for the target entry.",
|
|
89
105
|
},
|
|
90
106
|
{
|
|
91
107
|
flag: "--where",
|
|
@@ -112,13 +128,13 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
112
128
|
{
|
|
113
129
|
flag: "--explain",
|
|
114
130
|
description:
|
|
115
|
-
"`show` only — include the raw `WinBoxCdbRecord` in `data.record
|
|
131
|
+
"`show` only — include the raw `WinBoxCdbRecord` in `data.record` (its password field reads `<redacted>` unless `--show-password`).",
|
|
116
132
|
},
|
|
117
133
|
{
|
|
118
134
|
flag: "--match",
|
|
119
135
|
valueName: "<user=…|target=…|record-type>",
|
|
120
136
|
description:
|
|
121
|
-
"`show` — disambiguate when `<router>` matches several records: `user=<name>`, `target=<addr>`, or a record-type token.",
|
|
137
|
+
"`show`,`set`,`remove` — disambiguate when `<router>` matches several records: `user=<name>`, `target=<addr>`, or a record-type token.",
|
|
122
138
|
},
|
|
123
139
|
{
|
|
124
140
|
flag: "--user",
|
|
@@ -130,6 +146,16 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
130
146
|
valueName: "<secret>",
|
|
131
147
|
description: "`add`,`set` — first-class CDB password field.",
|
|
132
148
|
},
|
|
149
|
+
{
|
|
150
|
+
flag: "--password-stdin",
|
|
151
|
+
description:
|
|
152
|
+
"`add`,`set` — read the record password from stdin (one trailing newline stripped) so it never rides argv. Conflicts with `--password`.",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
flag: "--show-password",
|
|
156
|
+
description:
|
|
157
|
+
"`show`,`add`,`set` — include the record password in cleartext. By default output carries `passwordSet` and withholds the password.",
|
|
158
|
+
},
|
|
133
159
|
{
|
|
134
160
|
flag: "--profile",
|
|
135
161
|
valueName: "<name>",
|
|
@@ -209,6 +235,21 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
209
235
|
],
|
|
210
236
|
};
|
|
211
237
|
|
|
238
|
+
export const devicesCommand: CliCommandMetadata = {
|
|
239
|
+
...devicesMetadata,
|
|
240
|
+
subcommands: (["init", "encrypt"] as const).map((name) => ({
|
|
241
|
+
name,
|
|
242
|
+
usage: `centrs devices ${name} [flags]`,
|
|
243
|
+
summary:
|
|
244
|
+
name === "init"
|
|
245
|
+
? "Create an empty CDB at the resolved path; existing readable files are left unchanged."
|
|
246
|
+
: "Encrypt an existing CDB in place, or verify an already-encrypted file; existing backups remain unchanged.",
|
|
247
|
+
options: devicesMetadata.options.filter((option) =>
|
|
248
|
+
CDB_FILE_FLAGS[name].includes(option.flag),
|
|
249
|
+
),
|
|
250
|
+
})),
|
|
251
|
+
};
|
|
252
|
+
|
|
212
253
|
type DevicesSubcommand =
|
|
213
254
|
| "init"
|
|
214
255
|
| "encrypt"
|
|
@@ -248,6 +289,8 @@ interface DevicesCliArgs {
|
|
|
248
289
|
match?: string;
|
|
249
290
|
user?: string;
|
|
250
291
|
password?: string;
|
|
292
|
+
passwordStdin?: boolean;
|
|
293
|
+
showPassword?: boolean;
|
|
251
294
|
profile?: string;
|
|
252
295
|
profileNone?: boolean;
|
|
253
296
|
profileOwn?: boolean;
|
|
@@ -268,22 +311,6 @@ interface DevicesCliArgs {
|
|
|
268
311
|
bbox?: BboxPredicate;
|
|
269
312
|
}
|
|
270
313
|
|
|
271
|
-
/**
|
|
272
|
-
* Flags `init`/`encrypt` honor. They touch only the CDB file, never a record,
|
|
273
|
-
* so record, selection, and provenance flags would be silently ignored there.
|
|
274
|
-
*/
|
|
275
|
-
const CDB_FILE_FLAGS: Readonly<Record<"init" | "encrypt", readonly string[]>> =
|
|
276
|
-
{
|
|
277
|
-
init: ["--cdb-file", "--cdb-password", "--format", "--json", "--encrypt"],
|
|
278
|
-
encrypt: [
|
|
279
|
-
"--cdb-file",
|
|
280
|
-
"--cdb-password",
|
|
281
|
-
"--format",
|
|
282
|
-
"--json",
|
|
283
|
-
"--no-backup",
|
|
284
|
-
],
|
|
285
|
-
};
|
|
286
|
-
|
|
287
314
|
function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
288
315
|
const parsed: DevicesCliArgs = {};
|
|
289
316
|
const positional: string[] = [];
|
|
@@ -344,6 +371,18 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
344
371
|
break;
|
|
345
372
|
case "--password":
|
|
346
373
|
parsed.password = expectValue(args, ++index, arg);
|
|
374
|
+
// `--password --password-stdin` would otherwise store the literal
|
|
375
|
+
// flag as the password and never read stdin. Treat it as both flags
|
|
376
|
+
// so the conflict check below refuses it.
|
|
377
|
+
if (parsed.password === "--password-stdin") {
|
|
378
|
+
parsed.passwordStdin = true;
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
case "--password-stdin":
|
|
382
|
+
parsed.passwordStdin = true;
|
|
383
|
+
break;
|
|
384
|
+
case "--show-password":
|
|
385
|
+
parsed.showPassword = true;
|
|
347
386
|
break;
|
|
348
387
|
case "--profile":
|
|
349
388
|
parsed.profile = expectValue(args, ++index, arg);
|
|
@@ -415,6 +454,8 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
415
454
|
}
|
|
416
455
|
|
|
417
456
|
if (parsed.help) {
|
|
457
|
+
const sub = positional[0];
|
|
458
|
+
if (sub === "init" || sub === "encrypt") parsed.subcommand = sub;
|
|
418
459
|
return parsed;
|
|
419
460
|
}
|
|
420
461
|
|
|
@@ -504,6 +545,39 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
504
545
|
}
|
|
505
546
|
}
|
|
506
547
|
|
|
548
|
+
if (parsed.passwordStdin && sub !== "add" && sub !== "set") {
|
|
549
|
+
throw new CentrsError({
|
|
550
|
+
code: "input/invalid-command",
|
|
551
|
+
summary: `\`--password-stdin\` is not supported by \`centrs devices ${sub}\`.`,
|
|
552
|
+
remediation:
|
|
553
|
+
"`--password-stdin` writes a record password with `centrs devices add`/`set` (e.g. `printf '%s' \"$PW\" | centrs devices set <target> --password-stdin`).",
|
|
554
|
+
context: { subcommand: sub, flag: "--password-stdin" },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (parsed.passwordStdin && parsed.password !== undefined) {
|
|
558
|
+
throw new CentrsError({
|
|
559
|
+
code: "usage/conflicting-flags",
|
|
560
|
+
summary: "`--password` and `--password-stdin` cannot be used together.",
|
|
561
|
+
remediation:
|
|
562
|
+
"Pass the record password one way: `--password-stdin` with the value on stdin (keeps it out of argv and `ps`), or `--password <secret>`.",
|
|
563
|
+
context: { subcommand: sub, flags: ["--password", "--password-stdin"] },
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (
|
|
567
|
+
parsed.showPassword === true &&
|
|
568
|
+
sub !== "show" &&
|
|
569
|
+
sub !== "add" &&
|
|
570
|
+
sub !== "set"
|
|
571
|
+
) {
|
|
572
|
+
throw new CentrsError({
|
|
573
|
+
code: "input/invalid-command",
|
|
574
|
+
summary: `\`--show-password\` is not supported by \`centrs devices ${sub}\`.`,
|
|
575
|
+
remediation:
|
|
576
|
+
"`--show-password` reveals the record password in `centrs devices show`, `add` and `set` output. Run `centrs devices show <target> --show-password`.",
|
|
577
|
+
context: { subcommand: sub, flag: "--show-password" },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
507
581
|
if (sub === "show" || sub === "edit" || sub === "remove") {
|
|
508
582
|
if (rest.length !== 1) {
|
|
509
583
|
throw new Error(
|
|
@@ -663,17 +737,105 @@ function resolveProfile(parsed: DevicesCliArgs): string | undefined {
|
|
|
663
737
|
return parsed.profile;
|
|
664
738
|
}
|
|
665
739
|
|
|
740
|
+
/** Seams for {@link readPasswordStdin}; production reads the real fd 0. */
|
|
741
|
+
export interface PasswordStdinSource {
|
|
742
|
+
/** Whether stdin is a terminal (defaults to `process.stdin.isTTY`). */
|
|
743
|
+
isTty?: boolean;
|
|
744
|
+
/** Read all of stdin as raw bytes (defaults to `Bun.stdin.bytes()`). */
|
|
745
|
+
read?: () => Promise<Uint8Array>;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// `fatal` rejects malformed UTF-8 instead of substituting U+FFFD; `ignoreBOM`
|
|
749
|
+
// keeps a leading U+FEFF visible so it can be refused rather than dropped.
|
|
750
|
+
const stdinPasswordDecoder = new TextDecoder("utf-8", {
|
|
751
|
+
fatal: true,
|
|
752
|
+
ignoreBOM: true,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Read a record password for `devices add`/`set --password-stdin` (#582).
|
|
757
|
+
*
|
|
758
|
+
* Exactly one trailing `\n` or `\r\n` is stripped (at the byte level) so
|
|
759
|
+
* `echo pw | …` works; any other whitespace is part of the password. The rest
|
|
760
|
+
* must be valid UTF-8 without a leading byte-order mark: the CDB stores record
|
|
761
|
+
* strings as UTF-8 and drops a leading BOM when it reads them back, so either
|
|
762
|
+
* would silently store a credential different from the bytes piped in. A TTY stdin is refused rather
|
|
763
|
+
* than prompted (interactive prompting is #160), and empty input is refused so
|
|
764
|
+
* clearing a stored password stays the deliberate `--password ""`. The value is
|
|
765
|
+
* never placed in an error summary, remediation, or context.
|
|
766
|
+
*/
|
|
767
|
+
export async function readPasswordStdin(
|
|
768
|
+
source: PasswordStdinSource = {},
|
|
769
|
+
): Promise<string> {
|
|
770
|
+
const isTty = source.isTty ?? process.stdin.isTTY === true;
|
|
771
|
+
if (isTty) {
|
|
772
|
+
throw new CentrsError({
|
|
773
|
+
code: "usage/stdin-is-tty",
|
|
774
|
+
summary:
|
|
775
|
+
"`--password-stdin` reads the password from stdin, but stdin is a terminal.",
|
|
776
|
+
remediation:
|
|
777
|
+
"Pipe the password in (e.g. `printf '%s' \"$PW\" | centrs devices set <target> --password-stdin`) or redirect it from a file with `< pw.txt`. Interactive prompting is not supported.",
|
|
778
|
+
context: { flag: "--password-stdin" },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
const raw = await (source.read ?? (() => Bun.stdin.bytes()))();
|
|
782
|
+
const end = raw.length;
|
|
783
|
+
const trimmed =
|
|
784
|
+
end >= 2 && raw[end - 2] === 0x0d && raw[end - 1] === 0x0a
|
|
785
|
+
? raw.subarray(0, end - 2)
|
|
786
|
+
: end >= 1 && raw[end - 1] === 0x0a
|
|
787
|
+
? raw.subarray(0, end - 1)
|
|
788
|
+
: raw;
|
|
789
|
+
if (trimmed.length === 0) {
|
|
790
|
+
throw new CentrsError({
|
|
791
|
+
code: "input/empty-stdin",
|
|
792
|
+
summary: "`--password-stdin` received an empty password on stdin.",
|
|
793
|
+
remediation:
|
|
794
|
+
'Pipe a non-empty password to stdin. To clear a stored password on purpose, use `centrs devices set <target> --password ""`.',
|
|
795
|
+
context: { flag: "--password-stdin" },
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const invalid = (reason: string) =>
|
|
799
|
+
new CentrsError({
|
|
800
|
+
code: "input/invalid-stdin",
|
|
801
|
+
summary: `\`--password-stdin\` received ${reason}.`,
|
|
802
|
+
remediation:
|
|
803
|
+
"Pipe the password as UTF-8 text with no byte-order mark (e.g. `printf '%s' \"$PW\" | centrs devices set <target> --password-stdin`). Re-save a file written by an editor as UTF-8 without BOM.",
|
|
804
|
+
context: { flag: "--password-stdin", byteLength: trimmed.length },
|
|
805
|
+
});
|
|
806
|
+
let value: string;
|
|
807
|
+
try {
|
|
808
|
+
value = stdinPasswordDecoder.decode(trimmed);
|
|
809
|
+
} catch {
|
|
810
|
+
throw invalid("bytes that are not valid UTF-8");
|
|
811
|
+
}
|
|
812
|
+
if (value.startsWith("\uFEFF")) {
|
|
813
|
+
throw invalid("a leading byte-order mark (U+FEFF)");
|
|
814
|
+
}
|
|
815
|
+
return value;
|
|
816
|
+
}
|
|
817
|
+
|
|
666
818
|
export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
667
819
|
let parsed: DevicesCliArgs | undefined;
|
|
668
820
|
let envSnapshot: Record<string, string | undefined> | undefined;
|
|
669
821
|
try {
|
|
670
822
|
parsed = parseDevicesCliArgs(args);
|
|
671
823
|
if (parsed.help) {
|
|
672
|
-
|
|
824
|
+
const command =
|
|
825
|
+
devicesCommand.subcommands?.find(
|
|
826
|
+
(subcommand) => subcommand.name === parsed?.subcommand,
|
|
827
|
+
) ?? devicesCommand;
|
|
828
|
+
console.log(renderCommandHelp(describeCentrs(), command));
|
|
673
829
|
return 0;
|
|
674
830
|
}
|
|
675
831
|
|
|
676
832
|
envSnapshot = Bun.env;
|
|
833
|
+
// Read stdin before touching the CDB so a TTY or empty pipe fails fast
|
|
834
|
+
// without loading (or decrypting) anything. Only add/set get here with
|
|
835
|
+
// the flag; the parser refuses it on every other subcommand.
|
|
836
|
+
const password = parsed.passwordStdin
|
|
837
|
+
? await readPasswordStdin()
|
|
838
|
+
: parsed.password;
|
|
677
839
|
if (parsed.subcommand === "init" || parsed.subcommand === "encrypt") {
|
|
678
840
|
const options = {
|
|
679
841
|
cdbFile: parsed.cdbFile,
|
|
@@ -715,6 +877,7 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
715
877
|
match: parsed.match,
|
|
716
878
|
via: parsed.via,
|
|
717
879
|
env: envSnapshot,
|
|
880
|
+
showPassword: parsed.showPassword,
|
|
718
881
|
});
|
|
719
882
|
break;
|
|
720
883
|
case "groups":
|
|
@@ -738,7 +901,7 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
738
901
|
target: parsed.target,
|
|
739
902
|
recordType,
|
|
740
903
|
user: parsed.user,
|
|
741
|
-
password
|
|
904
|
+
password,
|
|
742
905
|
group: parsed.group,
|
|
743
906
|
profile: resolveProfile(parsed),
|
|
744
907
|
session: parsed.session,
|
|
@@ -749,6 +912,7 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
749
912
|
],
|
|
750
913
|
force: parsed.force,
|
|
751
914
|
strict: parsed.strict,
|
|
915
|
+
showPassword: parsed.showPassword,
|
|
752
916
|
});
|
|
753
917
|
break;
|
|
754
918
|
}
|
|
@@ -769,11 +933,12 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
769
933
|
...(parsed.kvArgs ?? []).map(parseKvArg),
|
|
770
934
|
],
|
|
771
935
|
user: parsed.user,
|
|
772
|
-
password
|
|
936
|
+
password,
|
|
773
937
|
group: parsed.group,
|
|
774
938
|
profile: resolveProfile(parsed),
|
|
775
939
|
session: parsed.session,
|
|
776
940
|
strict: parsed.strict,
|
|
941
|
+
showPassword: parsed.showPassword,
|
|
777
942
|
});
|
|
778
943
|
break;
|
|
779
944
|
case "remove":
|
package/src/cli/execute.ts
CHANGED
|
@@ -58,11 +58,11 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
58
58
|
usage:
|
|
59
59
|
"centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --host <host|url> [--] <command> [flags] | centrs execute --group <name> <command> [flags]",
|
|
60
60
|
summary:
|
|
61
|
-
"Run a RouterOS read or write command via native API, REST, or mac-telnet.",
|
|
61
|
+
"Run a RouterOS read or write command via native API, REST, SSH, or mac-telnet.",
|
|
62
62
|
options: [
|
|
63
63
|
{
|
|
64
64
|
flag: "--via",
|
|
65
|
-
valueName: "<native-api|rest-api|mac-telnet>",
|
|
65
|
+
valueName: "<native-api|rest-api|ssh|mac-telnet>",
|
|
66
66
|
description:
|
|
67
67
|
"Pin the protocol selector; no silent downgrade when set. A bare MAC target defaults to mac-telnet.",
|
|
68
68
|
},
|
|
@@ -141,7 +141,7 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
141
141
|
{
|
|
142
142
|
flag: "--validate / --no-validate",
|
|
143
143
|
description:
|
|
144
|
-
"Run RouterOS :parse
|
|
144
|
+
"Run offline analysis, then RouterOS :parse, plus `/console/inspect` for structured commands over native API/REST (default true; `--validate=false` disables both stages).",
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
flag: "--yes",
|
package/src/cli/settings.ts
CHANGED
|
@@ -191,6 +191,14 @@ function parseSettingsCliArgs(args: readonly string[]): SettingsCliArgs {
|
|
|
191
191
|
result.value = rest[1];
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// A blank <attr> is the caller's input, so reject it here; after parsing,
|
|
195
|
+
// an untyped throw is reported as internal/* (#587).
|
|
196
|
+
if (result.attr !== undefined && result.attr.trim() === "") {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`\`centrs settings ${result.subcommand}\` requires a non-empty <attr>.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
194
202
|
return result;
|
|
195
203
|
}
|
|
196
204
|
|
|
@@ -249,12 +257,26 @@ export async function runSettingsCli(args: readonly string[]): Promise<number> {
|
|
|
249
257
|
} catch (error) {
|
|
250
258
|
const format = parsed?.format ?? inferSettingsFormat(args);
|
|
251
259
|
const settingsFile = defaultSettingsPath(envSnapshot ?? Bun.env);
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
260
|
+
const summary = error instanceof Error ? error.message : String(error);
|
|
261
|
+
// Only argument parsing reports the caller's input. Once the arguments
|
|
262
|
+
// parsed, an untyped throw (a core or renderer fault) is a centrs bug,
|
|
263
|
+
// not something `--help` can fix (#587).
|
|
264
|
+
const centrsError = asCentrsError(
|
|
265
|
+
error,
|
|
266
|
+
parsed === undefined
|
|
267
|
+
? {
|
|
268
|
+
code: "input/invalid-command",
|
|
269
|
+
summary,
|
|
270
|
+
remediation:
|
|
271
|
+
"Use `centrs settings --help` to inspect the supported subcommands and flags.",
|
|
272
|
+
}
|
|
273
|
+
: {
|
|
274
|
+
code: "internal/settings-failed",
|
|
275
|
+
summary,
|
|
276
|
+
remediation:
|
|
277
|
+
"This is a centrs bug; re-run with `--json` and report the command and the error.",
|
|
278
|
+
},
|
|
279
|
+
);
|
|
258
280
|
const envelope = buildSettingsErrorEnvelope(
|
|
259
281
|
parsed?.subcommand ?? "print",
|
|
260
282
|
settingsFile,
|
|
@@ -154,6 +154,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
154
154
|
},
|
|
155
155
|
|
|
156
156
|
// input/*
|
|
157
|
+
{
|
|
158
|
+
code: "input/empty-stdin",
|
|
159
|
+
summary:
|
|
160
|
+
"A flag that reads its value from stdin (e.g. `--password-stdin`) received nothing.",
|
|
161
|
+
},
|
|
157
162
|
{
|
|
158
163
|
code: "input/incomplete-gps",
|
|
159
164
|
summary: "lat/lon (or a --gps tuple) were not given as a complete pair.",
|
|
@@ -200,6 +205,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
200
205
|
summary:
|
|
201
206
|
"The RouterOS path is malformed: not slash-prefixed, a segment that is not a menu name, or characters a REST URL would reinterpret.",
|
|
202
207
|
},
|
|
208
|
+
{
|
|
209
|
+
code: "input/invalid-stdin",
|
|
210
|
+
summary:
|
|
211
|
+
"A flag that reads its value from stdin (e.g. `--password-stdin`) received bytes that are not valid UTF-8 text, or began with a byte-order mark.",
|
|
212
|
+
},
|
|
203
213
|
{
|
|
204
214
|
code: "input/invalid-target",
|
|
205
215
|
summary: "The target string is invalid for the requested operation.",
|
|
@@ -668,6 +678,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
668
678
|
summary:
|
|
669
679
|
"stdin was redirected but the input came from the positional argument, so anything piped in was not analyzed.",
|
|
670
680
|
},
|
|
681
|
+
{
|
|
682
|
+
code: "usage/stdin-is-tty",
|
|
683
|
+
summary:
|
|
684
|
+
"A flag that reads its value from stdin (e.g. `--password-stdin`) was given while stdin is a terminal.",
|
|
685
|
+
},
|
|
671
686
|
{
|
|
672
687
|
code: "usage/fanout-not-supported",
|
|
673
688
|
summary:
|