@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +121 -6
  2. package/GLOSSARY.txt +9 -1
  3. package/README.md +43 -29
  4. package/commands/api/AGENTS.md +3 -2
  5. package/commands/api/README.md +8 -1
  6. package/commands/btest/README.md +9 -0
  7. package/commands/btest/examples.md +7 -0
  8. package/commands/devices/README.md +160 -30
  9. package/commands/devices/examples.md +168 -2
  10. package/commands/execute/README.md +13 -2
  11. package/commands/explain/README.md +223 -3
  12. package/commands/explain/examples.md +8 -1
  13. package/commands/retrieve/README.md +24 -3
  14. package/commands/retrieve/examples.md +84 -0
  15. package/commands/settings/README.md +6 -0
  16. package/commands/settings/examples.md +17 -0
  17. package/commands/transfer/README.md +25 -0
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +53 -11
  20. package/docs/MATRIX.md +5 -3
  21. package/docs/errors/cdb/encrypt-failed.md +10 -0
  22. package/docs/errors/cdb/password-required.md +5 -4
  23. package/docs/errors/cdb/plaintext-backups.md +11 -0
  24. package/docs/errors/input/empty-stdin.md +27 -0
  25. package/docs/errors/input/invalid-routeros-path.md +25 -3
  26. package/docs/errors/input/invalid-stdin.md +28 -0
  27. package/docs/errors/usage/stdin-is-tty.md +21 -0
  28. package/package.json +11 -7
  29. package/src/api-fanout.ts +2 -0
  30. package/src/api.ts +12 -0
  31. package/src/btest.ts +7 -0
  32. package/src/cli/api.ts +19 -2
  33. package/src/cli/btest.ts +9 -0
  34. package/src/cli/common.ts +1 -0
  35. package/src/cli/devices.ts +248 -15
  36. package/src/cli/execute.ts +12 -3
  37. package/src/cli/retrieve.ts +9 -0
  38. package/src/cli/settings.ts +28 -6
  39. package/src/cli/terminal.ts +9 -0
  40. package/src/cli/transfer.ts +10 -1
  41. package/src/core/error-catalog.ts +28 -2
  42. package/src/core/menu-path.ts +89 -0
  43. package/src/devices.ts +378 -33
  44. package/src/execute-fanout.ts +2 -0
  45. package/src/execute.ts +7 -0
  46. package/src/explain/blocks.ts +2 -2
  47. package/src/explain/catalog.ts +22 -12
  48. package/src/explain/pasted-prompt.ts +198 -0
  49. package/src/explain/pathresolve.ts +2 -2
  50. package/src/explain/segment.ts +2 -2
  51. package/src/explain/symbols.ts +2 -2
  52. package/src/explain/verbsplit.ts +2 -2
  53. package/src/explain/write.ts +2 -2
  54. package/src/explain.ts +2 -0
  55. package/src/index.ts +5 -0
  56. package/src/mcp/safety.ts +7 -6
  57. package/src/mcp/tools.ts +30 -68
  58. package/src/protocols/adapter.ts +2 -0
  59. package/src/protocols/btest-session.ts +10 -30
  60. package/src/protocols/ec-srp5.ts +19 -10
  61. package/src/protocols/mac-telnet-console.ts +2 -2
  62. package/src/resolver/cdb.ts +66 -9
  63. package/src/resolver/index.ts +1 -0
  64. package/src/resolver/quickchr-provider.ts +5 -0
  65. package/src/resolver/selection.ts +15 -4
  66. package/src/retrieve-fanout.ts +2 -0
  67. package/src/retrieve.ts +11 -0
  68. package/src/settings.ts +24 -8
  69. package/src/terminal.ts +7 -0
  70. package/src/transfer-fanout.ts +2 -0
  71. package/src/transfer.ts +79 -14
@@ -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
- export const devicesCommand: CliCommandMetadata = {
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
- "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used to re-encrypt on writes.",
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`,`edit` first-class group field for the target entry.",
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
- return { key, value: parseKvValue(token.slice(eq + 1)) };
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
- console.log(renderCommandHelp(describeCentrs(), devicesCommand));
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
- if (parsed.subcommand === "init") {
614
- const envelope = await initCdb({
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: parsed.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: parsed.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":
@@ -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 and /console/inspect validation before execution (default true; `--validate=false` also accepted).",
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;
@@ -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;
@@ -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 centrsError = asCentrsError(error, {
253
- code: "input/invalid-command",
254
- summary: error instanceof Error ? error.message : String(error),
255
- remediation:
256
- "Use `centrs settings --help` to inspect the supported subcommands and flags.",
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,
@@ -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;
@@ -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: "The CDB is encrypted; a --cdb-password is required to read it.",
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: "The RouterOS path is malformed (it must be slash-prefixed).",
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: