@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.
@@ -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-next.27",
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.2",
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.2",
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.3.11 <2"
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
- "Run `/console/inspect` validation before the request (default true; `--validate=false` also accepted).",
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
@@ -63,6 +63,7 @@ export interface CliCommandMetadata {
63
63
  usage: string;
64
64
  summary: string;
65
65
  options: readonly CliCommandOption[];
66
+ subcommands?: readonly CliCommandMetadata[];
66
67
  }
67
68
 
68
69
  export function expectValue(
@@ -46,7 +46,23 @@ import {
46
46
  unknownFlagError,
47
47
  } from "./common.ts";
48
48
 
49
- 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 = {
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
- "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used for `init --encrypt`, `encrypt`, and 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.",
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`,`edit` first-class group field for the target entry.",
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
- 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));
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: parsed.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: parsed.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":
@@ -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 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).",
145
145
  },
146
146
  {
147
147
  flag: "--yes",
@@ -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,
@@ -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: