@tikoci/centrs 0.1.9-next.24 → 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 +121 -6
- package/GLOSSARY.txt +9 -1
- package/README.md +43 -29
- package/commands/api/AGENTS.md +3 -2
- package/commands/api/README.md +8 -1
- package/commands/btest/README.md +9 -0
- package/commands/btest/examples.md +7 -0
- package/commands/devices/README.md +160 -30
- package/commands/devices/examples.md +168 -2
- package/commands/execute/README.md +13 -2
- package/commands/explain/README.md +223 -3
- package/commands/explain/examples.md +8 -1
- package/commands/retrieve/README.md +24 -3
- package/commands/retrieve/examples.md +84 -0
- package/commands/settings/README.md +6 -0
- package/commands/settings/examples.md +17 -0
- package/commands/transfer/README.md +25 -0
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +53 -11
- package/docs/MATRIX.md +5 -3
- package/docs/errors/cdb/encrypt-failed.md +10 -0
- package/docs/errors/cdb/password-required.md +5 -4
- package/docs/errors/cdb/plaintext-backups.md +11 -0
- package/docs/errors/input/empty-stdin.md +27 -0
- package/docs/errors/input/invalid-routeros-path.md +25 -3
- package/docs/errors/input/invalid-stdin.md +28 -0
- package/docs/errors/usage/stdin-is-tty.md +21 -0
- package/package.json +11 -7
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +12 -0
- package/src/btest.ts +7 -0
- package/src/cli/api.ts +19 -2
- package/src/cli/btest.ts +9 -0
- package/src/cli/common.ts +1 -0
- package/src/cli/devices.ts +248 -15
- package/src/cli/execute.ts +12 -3
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/settings.ts +28 -6
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/error-catalog.ts +28 -2
- package/src/core/menu-path.ts +89 -0
- package/src/devices.ts +378 -33
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +7 -0
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/segment.ts +2 -2
- package/src/explain/symbols.ts +2 -2
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- package/src/explain.ts +2 -0
- package/src/index.ts +5 -0
- package/src/mcp/safety.ts +7 -6
- package/src/mcp/tools.ts +30 -68
- package/src/protocols/adapter.ts +2 -0
- package/src/protocols/btest-session.ts +10 -30
- package/src/protocols/ec-srp5.ts +19 -10
- package/src/protocols/mac-telnet-console.ts +2 -2
- package/src/resolver/cdb.ts +66 -9
- package/src/resolver/index.ts +1 -0
- package/src/resolver/quickchr-provider.ts +5 -0
- package/src/resolver/selection.ts +15 -4
- package/src/retrieve-fanout.ts +2 -0
- package/src/retrieve.ts +11 -0
- package/src/settings.ts +24 -8
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +79 -14
package/src/cli/devices.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
describeCentrs,
|
|
23
23
|
devicesOutputFormats,
|
|
24
24
|
editInteractiveOnlyError,
|
|
25
|
+
encryptCdb,
|
|
25
26
|
initCdb,
|
|
26
27
|
listDevices,
|
|
27
28
|
listGroups,
|
|
@@ -45,13 +46,39 @@ import {
|
|
|
45
46
|
unknownFlagError,
|
|
46
47
|
} from "./common.ts";
|
|
47
48
|
|
|
48
|
-
|
|
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 = {
|
|
49
66
|
name: "devices",
|
|
50
67
|
usage:
|
|
51
|
-
"centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
68
|
+
"centrs devices <init|encrypt|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
52
69
|
summary:
|
|
53
70
|
"Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
|
|
54
71
|
options: [
|
|
72
|
+
{
|
|
73
|
+
flag: "--encrypt",
|
|
74
|
+
description:
|
|
75
|
+
"`init` only — require encrypted creation with a non-empty CDB password; refuse an existing open CDB.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
flag: "--no-backup",
|
|
79
|
+
description:
|
|
80
|
+
"`encrypt` only — skip the plaintext pre-conversion snapshot. Without that snapshot, a forgotten password cannot be recovered.",
|
|
81
|
+
},
|
|
55
82
|
{
|
|
56
83
|
flag: "--cdb-file",
|
|
57
84
|
valueName: "<path>",
|
|
@@ -62,7 +89,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
62
89
|
flag: "--cdb-password",
|
|
63
90
|
valueName: "<secret>",
|
|
64
91
|
description:
|
|
65
|
-
"
|
|
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.",
|
|
66
93
|
},
|
|
67
94
|
{
|
|
68
95
|
flag: "--via",
|
|
@@ -74,7 +101,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
74
101
|
flag: "--group",
|
|
75
102
|
valueName: "<name>",
|
|
76
103
|
description:
|
|
77
|
-
"`list` filter / `add`,`
|
|
104
|
+
"`list` filter / `add`,`set` first-class group field for the target entry.",
|
|
78
105
|
},
|
|
79
106
|
{
|
|
80
107
|
flag: "--where",
|
|
@@ -101,13 +128,13 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
101
128
|
{
|
|
102
129
|
flag: "--explain",
|
|
103
130
|
description:
|
|
104
|
-
"`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`).",
|
|
105
132
|
},
|
|
106
133
|
{
|
|
107
134
|
flag: "--match",
|
|
108
135
|
valueName: "<user=…|target=…|record-type>",
|
|
109
136
|
description:
|
|
110
|
-
"`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.",
|
|
111
138
|
},
|
|
112
139
|
{
|
|
113
140
|
flag: "--user",
|
|
@@ -119,6 +146,16 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
119
146
|
valueName: "<secret>",
|
|
120
147
|
description: "`add`,`set` — first-class CDB password field.",
|
|
121
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
|
+
},
|
|
122
159
|
{
|
|
123
160
|
flag: "--profile",
|
|
124
161
|
valueName: "<name>",
|
|
@@ -198,8 +235,24 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
198
235
|
],
|
|
199
236
|
};
|
|
200
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
|
+
|
|
201
253
|
type DevicesSubcommand =
|
|
202
254
|
| "init"
|
|
255
|
+
| "encrypt"
|
|
203
256
|
| "list"
|
|
204
257
|
| "show"
|
|
205
258
|
| "groups"
|
|
@@ -226,6 +279,8 @@ interface DevicesCliArgs {
|
|
|
226
279
|
subcommand?: DevicesSubcommand;
|
|
227
280
|
cdbFile?: string;
|
|
228
281
|
cdbPassword?: string;
|
|
282
|
+
encrypt?: boolean;
|
|
283
|
+
noBackup?: boolean;
|
|
229
284
|
group?: string;
|
|
230
285
|
where?: string[];
|
|
231
286
|
via?: string;
|
|
@@ -234,6 +289,8 @@ interface DevicesCliArgs {
|
|
|
234
289
|
match?: string;
|
|
235
290
|
user?: string;
|
|
236
291
|
password?: string;
|
|
292
|
+
passwordStdin?: boolean;
|
|
293
|
+
showPassword?: boolean;
|
|
237
294
|
profile?: string;
|
|
238
295
|
profileNone?: boolean;
|
|
239
296
|
profileOwn?: boolean;
|
|
@@ -257,17 +314,25 @@ interface DevicesCliArgs {
|
|
|
257
314
|
function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
258
315
|
const parsed: DevicesCliArgs = {};
|
|
259
316
|
const positional: string[] = [];
|
|
317
|
+
const suppliedFlags: string[] = [];
|
|
260
318
|
|
|
261
319
|
for (let index = 0; index < args.length; index += 1) {
|
|
262
320
|
const arg = args[index];
|
|
263
321
|
if (arg === undefined) {
|
|
264
322
|
continue;
|
|
265
323
|
}
|
|
324
|
+
if (arg.startsWith("-")) suppliedFlags.push(arg);
|
|
266
325
|
switch (arg) {
|
|
267
326
|
case "--help":
|
|
268
327
|
case "-h":
|
|
269
328
|
parsed.help = true;
|
|
270
329
|
break;
|
|
330
|
+
case "--encrypt":
|
|
331
|
+
parsed.encrypt = true;
|
|
332
|
+
break;
|
|
333
|
+
case "--no-backup":
|
|
334
|
+
parsed.noBackup = true;
|
|
335
|
+
break;
|
|
271
336
|
case "--cdb-file":
|
|
272
337
|
parsed.cdbFile = expectValue(args, ++index, arg);
|
|
273
338
|
break;
|
|
@@ -306,6 +371,18 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
306
371
|
break;
|
|
307
372
|
case "--password":
|
|
308
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;
|
|
309
386
|
break;
|
|
310
387
|
case "--profile":
|
|
311
388
|
parsed.profile = expectValue(args, ++index, arg);
|
|
@@ -377,11 +454,14 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
377
454
|
}
|
|
378
455
|
|
|
379
456
|
if (parsed.help) {
|
|
457
|
+
const sub = positional[0];
|
|
458
|
+
if (sub === "init" || sub === "encrypt") parsed.subcommand = sub;
|
|
380
459
|
return parsed;
|
|
381
460
|
}
|
|
382
461
|
|
|
383
462
|
const subcommands: readonly DevicesSubcommand[] = [
|
|
384
463
|
"init",
|
|
464
|
+
"encrypt",
|
|
385
465
|
"list",
|
|
386
466
|
"show",
|
|
387
467
|
"groups",
|
|
@@ -393,17 +473,23 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
393
473
|
const [rawSub, ...rest] = positional;
|
|
394
474
|
if (rawSub === undefined) {
|
|
395
475
|
throw new Error(
|
|
396
|
-
"`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
|
|
476
|
+
"`centrs devices` requires a subcommand: init, encrypt, list, show, groups, add, edit, set, or remove.",
|
|
397
477
|
);
|
|
398
478
|
}
|
|
399
479
|
// Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
|
|
400
480
|
const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
|
|
401
481
|
if (!subcommands.includes(sub)) {
|
|
402
482
|
throw new Error(
|
|
403
|
-
`Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
483
|
+
`Unknown devices subcommand: ${rawSub}. Use init, encrypt, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
404
484
|
);
|
|
405
485
|
}
|
|
406
486
|
parsed.subcommand = sub;
|
|
487
|
+
if (parsed.encrypt && sub !== "init")
|
|
488
|
+
throw new Error(
|
|
489
|
+
"--encrypt is supported only by devices init; use devices encrypt for conversion.",
|
|
490
|
+
);
|
|
491
|
+
if (parsed.noBackup && sub !== "encrypt")
|
|
492
|
+
throw new Error("--no-backup is supported only by devices encrypt.");
|
|
407
493
|
|
|
408
494
|
// Reject flags that don't apply to the resolved subcommand instead of
|
|
409
495
|
// silently ignoring the user's input: `--where`/`--near`/`--bbox` select
|
|
@@ -446,6 +532,51 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
446
532
|
context: { subcommand: sub, flag: geoFlag },
|
|
447
533
|
});
|
|
448
534
|
}
|
|
535
|
+
if (sub === "init" || sub === "encrypt") {
|
|
536
|
+
const allowed = CDB_FILE_FLAGS[sub];
|
|
537
|
+
const ignored = suppliedFlags.find((flag) => !allowed.includes(flag));
|
|
538
|
+
if (ignored !== undefined) {
|
|
539
|
+
throw new CentrsError({
|
|
540
|
+
code: "input/invalid-command",
|
|
541
|
+
summary: `\`${ignored}\` is not supported by \`centrs devices ${sub}\`.`,
|
|
542
|
+
remediation: `\`centrs devices ${sub}\` accepts only ${allowed.map((flag) => `\`${flag}\``).join(", ")}. Edit records with \`centrs devices add\`/\`set\`.`,
|
|
543
|
+
context: { subcommand: sub, flag: ignored },
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
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
|
+
}
|
|
449
580
|
|
|
450
581
|
if (sub === "show" || sub === "edit" || sub === "remove") {
|
|
451
582
|
if (rest.length !== 1) {
|
|
@@ -511,7 +642,14 @@ function parseKvArg(token: string): CommentKvUpdate {
|
|
|
511
642
|
// canonical key a `--lon`/`--lat`/`--altitude` flag would use. A no-op for
|
|
512
643
|
// every non-geo key.
|
|
513
644
|
const key = canonicalizeGeoKey(token.slice(0, eq));
|
|
514
|
-
|
|
645
|
+
const value = parseKvValue(token.slice(eq + 1));
|
|
646
|
+
// An empty value removes the token (`devices set <t> via=` deletes the
|
|
647
|
+
// `via` override) instead of storing a literally-empty value, per
|
|
648
|
+
// `commands/devices/README.md` (Comment kv-soup). The core upsert/remove
|
|
649
|
+
// model lives in `applyCommentKv` (`src/resolver/comment-kv.ts`), which
|
|
650
|
+
// treats `value: null` as removal; the CLI lowers `key=` to that form
|
|
651
|
+
// here. There is currently no need to store a literally-empty value.
|
|
652
|
+
return { key, value: value === "" ? null : value };
|
|
515
653
|
}
|
|
516
654
|
|
|
517
655
|
/**
|
|
@@ -599,23 +737,115 @@ function resolveProfile(parsed: DevicesCliArgs): string | undefined {
|
|
|
599
737
|
return parsed.profile;
|
|
600
738
|
}
|
|
601
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
|
+
|
|
602
818
|
export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
603
819
|
let parsed: DevicesCliArgs | undefined;
|
|
604
820
|
let envSnapshot: Record<string, string | undefined> | undefined;
|
|
605
821
|
try {
|
|
606
822
|
parsed = parseDevicesCliArgs(args);
|
|
607
823
|
if (parsed.help) {
|
|
608
|
-
|
|
824
|
+
const command =
|
|
825
|
+
devicesCommand.subcommands?.find(
|
|
826
|
+
(subcommand) => subcommand.name === parsed?.subcommand,
|
|
827
|
+
) ?? devicesCommand;
|
|
828
|
+
console.log(renderCommandHelp(describeCentrs(), command));
|
|
609
829
|
return 0;
|
|
610
830
|
}
|
|
611
831
|
|
|
612
832
|
envSnapshot = Bun.env;
|
|
613
|
-
|
|
614
|
-
|
|
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;
|
|
839
|
+
if (parsed.subcommand === "init" || parsed.subcommand === "encrypt") {
|
|
840
|
+
const options = {
|
|
615
841
|
cdbFile: parsed.cdbFile,
|
|
616
842
|
cdbPassword: parsed.cdbPassword,
|
|
617
843
|
env: envSnapshot,
|
|
618
|
-
}
|
|
844
|
+
};
|
|
845
|
+
const envelope =
|
|
846
|
+
parsed.subcommand === "init"
|
|
847
|
+
? await initCdb({ ...options, encrypt: parsed.encrypt })
|
|
848
|
+
: await encryptCdb({ ...options, skipBackup: parsed.noBackup });
|
|
619
849
|
console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
|
|
620
850
|
return 0;
|
|
621
851
|
}
|
|
@@ -647,6 +877,7 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
647
877
|
match: parsed.match,
|
|
648
878
|
via: parsed.via,
|
|
649
879
|
env: envSnapshot,
|
|
880
|
+
showPassword: parsed.showPassword,
|
|
650
881
|
});
|
|
651
882
|
break;
|
|
652
883
|
case "groups":
|
|
@@ -670,7 +901,7 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
670
901
|
target: parsed.target,
|
|
671
902
|
recordType,
|
|
672
903
|
user: parsed.user,
|
|
673
|
-
password
|
|
904
|
+
password,
|
|
674
905
|
group: parsed.group,
|
|
675
906
|
profile: resolveProfile(parsed),
|
|
676
907
|
session: parsed.session,
|
|
@@ -681,6 +912,7 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
681
912
|
],
|
|
682
913
|
force: parsed.force,
|
|
683
914
|
strict: parsed.strict,
|
|
915
|
+
showPassword: parsed.showPassword,
|
|
684
916
|
});
|
|
685
917
|
break;
|
|
686
918
|
}
|
|
@@ -701,11 +933,12 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
701
933
|
...(parsed.kvArgs ?? []).map(parseKvArg),
|
|
702
934
|
],
|
|
703
935
|
user: parsed.user,
|
|
704
|
-
password
|
|
936
|
+
password,
|
|
705
937
|
group: parsed.group,
|
|
706
938
|
profile: resolveProfile(parsed),
|
|
707
939
|
session: parsed.session,
|
|
708
940
|
strict: parsed.strict,
|
|
941
|
+
showPassword: parsed.showPassword,
|
|
709
942
|
});
|
|
710
943
|
break;
|
|
711
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
|
},
|
|
@@ -121,6 +121,12 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
121
121
|
valueName: "<password>",
|
|
122
122
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
123
123
|
},
|
|
124
|
+
{
|
|
125
|
+
flag: "--match",
|
|
126
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
127
|
+
description:
|
|
128
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
129
|
+
},
|
|
124
130
|
{
|
|
125
131
|
flag: "--resolve",
|
|
126
132
|
valueName: "<none|arp>",
|
|
@@ -135,7 +141,7 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
135
141
|
{
|
|
136
142
|
flag: "--validate / --no-validate",
|
|
137
143
|
description:
|
|
138
|
-
"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).",
|
|
139
145
|
},
|
|
140
146
|
{
|
|
141
147
|
flag: "--yes",
|
|
@@ -262,6 +268,9 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
|
262
268
|
case "--cdb-password":
|
|
263
269
|
parsed.cdbPassword = expectValue(args, ++index, arg);
|
|
264
270
|
break;
|
|
271
|
+
case "--match":
|
|
272
|
+
parsed.match = expectValue(args, ++index, arg);
|
|
273
|
+
break;
|
|
265
274
|
case "--resolve":
|
|
266
275
|
parsed.resolve = expectValue(args, ++index, arg);
|
|
267
276
|
break;
|
package/src/cli/retrieve.ts
CHANGED
|
@@ -199,6 +199,12 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
199
199
|
valueName: "<password>",
|
|
200
200
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
201
201
|
},
|
|
202
|
+
{
|
|
203
|
+
flag: "--match",
|
|
204
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
205
|
+
description:
|
|
206
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
207
|
+
},
|
|
202
208
|
{
|
|
203
209
|
flag: "--resolve",
|
|
204
210
|
valueName: "<none|arp>",
|
|
@@ -537,6 +543,9 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
|
|
|
537
543
|
case "--cdb-password":
|
|
538
544
|
request.cdbPassword = expectValue(args, ++index, arg);
|
|
539
545
|
break;
|
|
546
|
+
case "--match":
|
|
547
|
+
request.match = expectValue(args, ++index, arg);
|
|
548
|
+
break;
|
|
540
549
|
case "--resolve":
|
|
541
550
|
request.resolve = expectValue(args, ++index, arg);
|
|
542
551
|
break;
|
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,
|
package/src/cli/terminal.ts
CHANGED
|
@@ -97,6 +97,12 @@ export const terminalCommand: CliCommandMetadata = {
|
|
|
97
97
|
valueName: "<password>",
|
|
98
98
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
flag: "--match",
|
|
102
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
103
|
+
description:
|
|
104
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
105
|
+
},
|
|
100
106
|
{
|
|
101
107
|
flag: "--resolve",
|
|
102
108
|
valueName: "<none|arp>",
|
|
@@ -259,6 +265,9 @@ export function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
|
|
|
259
265
|
case "--cdb-password":
|
|
260
266
|
flags.cdbPassword = expectValue(args, ++index, arg);
|
|
261
267
|
break;
|
|
268
|
+
case "--match":
|
|
269
|
+
flags.match = expectValue(args, ++index, arg);
|
|
270
|
+
break;
|
|
262
271
|
case "--resolve":
|
|
263
272
|
flags.resolve = expectValue(args, ++index, arg);
|
|
264
273
|
break;
|
package/src/cli/transfer.ts
CHANGED
|
@@ -73,7 +73,7 @@ export const transferCommand: CliCommandMetadata = {
|
|
|
73
73
|
flag: "--via",
|
|
74
74
|
valueName: "<method>",
|
|
75
75
|
description:
|
|
76
|
-
"Pin the method: rest, native, or sftp (large transfers); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction.",
|
|
76
|
+
"Pin the method: rest, native, or sftp (large transfers; `ssh` is an alias); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction, after a CDB record's `via=` (ssh→sftp).",
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
flag: "--ssh-key",
|
|
@@ -164,6 +164,12 @@ export const transferCommand: CliCommandMetadata = {
|
|
|
164
164
|
valueName: "<password>",
|
|
165
165
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
flag: "--match",
|
|
169
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
170
|
+
description:
|
|
171
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
172
|
+
},
|
|
167
173
|
{
|
|
168
174
|
flag: "--validate / --no-validate",
|
|
169
175
|
description: "Enable or disable the existence-probe preflight.",
|
|
@@ -414,6 +420,9 @@ export function parseTransferCliArgs(
|
|
|
414
420
|
case "--cdb-password":
|
|
415
421
|
flags.cdbPassword = expectValue(args, ++index, arg);
|
|
416
422
|
break;
|
|
423
|
+
case "--match":
|
|
424
|
+
flags.match = expectValue(args, ++index, arg);
|
|
425
|
+
break;
|
|
417
426
|
case "--validate":
|
|
418
427
|
flags.validate = true;
|
|
419
428
|
break;
|
|
@@ -53,6 +53,15 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
53
53
|
summary:
|
|
54
54
|
"The encrypted CDB could not be decrypted (wrong or missing password).",
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
code: "cdb/encrypt-failed",
|
|
58
|
+
summary: "The CDB could not be rewritten with encryption.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
code: "cdb/plaintext-backups",
|
|
62
|
+
summary:
|
|
63
|
+
"CDB backups may still contain plaintext credentials after encryption.",
|
|
64
|
+
},
|
|
56
65
|
{
|
|
57
66
|
code: "cdb/empty-group",
|
|
58
67
|
summary:
|
|
@@ -95,7 +104,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
95
104
|
},
|
|
96
105
|
{
|
|
97
106
|
code: "cdb/password-required",
|
|
98
|
-
summary:
|
|
107
|
+
summary:
|
|
108
|
+
"A CDB password is required for decryption or explicit encryption.",
|
|
99
109
|
},
|
|
100
110
|
{
|
|
101
111
|
code: "cdb/reserved-key",
|
|
@@ -144,6 +154,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
144
154
|
},
|
|
145
155
|
|
|
146
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
|
+
},
|
|
147
162
|
{
|
|
148
163
|
code: "input/incomplete-gps",
|
|
149
164
|
summary: "lat/lon (or a --gps tuple) were not given as a complete pair.",
|
|
@@ -187,7 +202,13 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
187
202
|
},
|
|
188
203
|
{
|
|
189
204
|
code: "input/invalid-routeros-path",
|
|
190
|
-
summary:
|
|
205
|
+
summary:
|
|
206
|
+
"The RouterOS path is malformed: not slash-prefixed, a segment that is not a menu name, or characters a REST URL would reinterpret.",
|
|
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.",
|
|
191
212
|
},
|
|
192
213
|
{
|
|
193
214
|
code: "input/invalid-target",
|
|
@@ -657,6 +678,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
657
678
|
summary:
|
|
658
679
|
"stdin was redirected but the input came from the positional argument, so anything piped in was not analyzed.",
|
|
659
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
|
+
},
|
|
660
686
|
{
|
|
661
687
|
code: "usage/fanout-not-supported",
|
|
662
688
|
summary:
|