@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
@@ -89,6 +89,17 @@ or a duplicated `identity=`):
89
89
  lists all matching entries (by `cdbRecordIndex`, `target`, `user`, and
90
90
  `recordType`). The caller pins the choice with `--match` by `user`
91
91
  (`--match user=admin`), by record-type (`--match ipUser`), or by exact target.
92
+ `--match` takes the same selector on `devices show`/`set`/`remove` and on
93
+ every command that resolves a `<router>` from the CDB: `retrieve`,
94
+ `execute`, `api`, `transfer`, `terminal` and `btest client` (#583). It is a
95
+ single-target flag: with a fan-out selection or `--quickchr` it is
96
+ `usage/conflicting-flags`, and when no CDB record is in play (no CDB, an
97
+ unregistered literal host, a `__default__`-only fallback, or `--host`
98
+ without `<router>`) it fails `identity/no-match` rather than being ignored.
99
+ With no target at all (no `<router>`, no `--host`) the CLI still reports
100
+ the missing-target usage error first.
101
+ An ambiguous positional inside a fan-out names the single-target
102
+ `--match` re-run, since the fan-out itself refuses the flag.
92
103
 
93
104
  `--prefer-family=ipv4|ipv6` is a non-interactive way to break v4/v6 ties
94
105
  deterministically; with it set, no prompt is raised. It emits a warning
@@ -141,6 +152,15 @@ Rules:
141
152
  - An **empty value removes** the token: `devices set <t> via=` deletes the `via`
142
153
  override (matching the internal upsert/remove model in `comment-kv.ts`). There
143
154
  is no current need to store a literally-empty value; revisit if one arises.
155
+ Removing a token that is already absent is a no-op success — the comment is
156
+ left byte-for-byte identical.
157
+ - First-class fields have their own empty-value rules, distinct from token
158
+ removal: `--password ""` clears the stored password (and its saved-password
159
+ flag); `--user ""` writes an empty user. Bare `user=` / `password=` /
160
+ `group=` / `profile=` / `session=` positionals never reach those fields —
161
+ they are refused with `cdb/reserved-key` — so comment-token removal (`key=`
162
+ deletes the token) and credential clearing (`--password ""` empties the
163
+ password) are deliberately different operations.
144
164
  - `commentMirror` is kept in sync with `comment` on every write.
145
165
 
146
166
  Precedence (lowest → highest): built-in defaults → user config file
@@ -290,32 +310,27 @@ is no group-definition record. A group is the set of all entries whose
290
310
  `group` value matches by exact, case-sensitive string compare. An entry with
291
311
  `group=""` belongs to no group.
292
312
 
293
- `--group <G>` on any command resolves to the membership set and fans out.
313
+ `--group <G>` on a router-facing fan-out command resolves to the membership set.
294
314
  `discover --save` writes MNDP records with `group=discovered`
295
315
  unless the caller supplies another group.
296
316
 
297
317
  ### Promotion (discovered → managed)
298
318
 
299
319
  There is no `promote` verb. Moving one device out of `group=discovered` and
300
- giving it credentials is `devices set <t> --new-group prod --user … --password
301
- …`. A **bulk** promotion (a whole discovered class at once) is the same call with
302
- a target selector picking the records:
320
+ giving it credentials uses `set` on that target:
303
321
 
304
322
  ```bash
305
- devices set --group discovered --new-group prod \
306
- --user admin --password … --force
323
+ centrs devices set <target> --group prod --user admin --password …
307
324
  ```
308
325
 
309
- `--group` (and `--where`/`--all`/positionals) always **selects** which records to
310
- modify; `--new-group` always **writes** the group field. The two are distinct
311
- flags, so there is no select-vs-set overload — this resolves the former wrinkle.
312
- (Code follow-up: the CLI still overloads `--group` as the `add`/`set` field
313
- setter today; `examples.md` reflects that current flag until the `--new-group`
314
- split lands.)
326
+ On `devices add`/`set`, `--group` writes the first-class group field; on
327
+ `devices list` it filters. Bulk `set`/`remove` selection and a separate
328
+ `--new-group` flag remain designed, not implemented. Both mutations currently
329
+ require one target, with `--match` to disambiguate saved records.
315
330
 
316
331
  ## Fanout (multi-target invocations)
317
332
 
318
- Any command may receive multiple positional targets, one or more `--group`
333
+ Router-facing fan-out commands may receive multiple positional targets, one or more `--group`
319
334
  flags, `--all`, `--default`, `--where <attr>=<value>` (device-class), and/or the
320
335
  geo selectors `--near <lat>,<lon>,<radius>` / `--bbox <south>,<west>,<north>,<east>`
321
336
  (see "Geo query" above) — see `docs/CONSTITUTION.md` (Target selection grammar).
@@ -340,25 +355,29 @@ runs produce stable diffs.
340
355
  ## Subcommands
341
356
 
342
357
  ```text
343
- centrs devices init # create an empty CDB at the resolved path
358
+ centrs devices init [--encrypt] # create an empty CDB at the resolved path
359
+ centrs devices encrypt [--no-backup] # explicitly encrypt an existing open CDB
344
360
  centrs devices list [--group G] [--where attr=value]
345
361
  [--near lat,lon,radius] [--bbox south,west,north,east]
346
362
  [--format json|yaml|text]
347
- centrs devices show <target> [--explain] [--via <protocol>] [--match <type>]
363
+ centrs devices show <target> [--explain] [--via <protocol>] [--match <selector>]
364
+ [--show-password]
348
365
  centrs devices groups [--members]
349
- centrs devices discover [--timeout 15s] [--group G] # = discover --save (save implied)
350
- centrs devices add <target> [--user U] [--password P] [--new-group G]
366
+ centrs devices discover [--timeout 15s] [--group G] # designed, not implemented; use `centrs discover --save`
367
+ centrs devices add <target> [--user U] [--password P|--password-stdin] [--group G]
351
368
  [--profile P|--profile-none|--profile-own]
352
369
  [--session S] [--comment "text k=v"]
353
370
  [--record-type ipAdmin|ipUser|macTarget|...]
354
371
  [--gps lat,lon[,altitude[,altitude-type]]]
355
372
  [--lat D] [--lon D] [--altitude M] [--altitude-type MSL|AGL]
356
- centrs devices set <selector…> [--user U] [--password P] [--new-group G]
373
+ [--show-password]
374
+ centrs devices set <target> [--match <selector>] [--user U] [--password P|--password-stdin] [--group G]
357
375
  [--profile P|--profile-none|--profile-own]
358
376
  [--session S] [k=v ...]
359
377
  [--gps lat,lon[,altitude[,altitude-type]]]
360
378
  [--lat D] [--lon D] [--altitude M] [--altitude-type MSL|AGL]
361
- centrs devices remove <selector…>
379
+ [--show-password]
380
+ centrs devices remove <target> [--match <selector>]
362
381
  centrs devices edit <target> # future: clack/TUI wizard
363
382
  ```
364
383
 
@@ -370,9 +389,11 @@ clack-style prompt used by `@tikoci/quickchr`); it is **not** a separate
370
389
  field-editing verb, and invoking it today returns `usage/not-implemented`. There
371
390
  is no `update`.
372
391
 
373
- - `init` creates an empty open CDB at the resolved path (`--cdb-file`,
392
+ - `init` creates an empty CDB (open by default, encrypted with `--encrypt`)
393
+ at the resolved path (`--cdb-file`,
374
394
  `CENTRS_CDB_FILE`, or the default), with parent directories and mode `0600`.
375
- It never overwrites: on an existing, readable CDB it succeeds with a
395
+ It never overwrites: on an existing, readable CDB matching the encryption
396
+ requirement it succeeds with a
376
397
  `cdb/file-exists` warning and writes nothing. An explicit path is created only
377
398
  by `init`; every other subcommand reports `cdb/not-found` for it (typo
378
399
  protection), and that error names `init`.
@@ -400,18 +421,79 @@ is no `update`.
400
421
  existing `(target, user)` it errors `cdb/already-exists` unless `--force`
401
422
  overwrites; the same `target` under a *different* `--user` is a new record,
402
423
  not a collision.
403
- - `set` modifies existing record(s). First-class fields change via flags
404
- (`--user`, `--password`, `--new-group`, `--profile[-none|-own]`, `--session`);
405
- comment lookup/override keys change via `k=v` positionals. **Which** records to
406
- modify is the target selector — positionals, `--group`, `--where`, `--all`
407
- (constitution: target selection) — so `--group` here *selects* and
408
- `--new-group` *writes* the group field (no overload). Against a missing target
409
- it errors `cdb/not-found-target`; a multi-record `set` needs `--force`. Writing
424
+ - `set` modifies one matched record. First-class fields change via flags
425
+ (`--user`, `--password`, `--group`, `--profile[-none|-own]`, `--session`);
426
+ comment lookup/override keys change via `k=v` positionals. The positional
427
+ target and optional `--match` select the record. Against a missing target
428
+ it errors `cdb/not-found-target`. Writing
410
429
  a first-class field name as a `k=v` positional (e.g. `user=x`) stays refused
411
430
  with `cdb/reserved-key` — the flag is the only path. Unknown comment keys warn
412
431
  (`cdb/unknown-option`), or error under `--strict`.
413
- - `remove` (aliases `rm`, `delete`) removes a single entry. Group-wide deletes
414
- (`--group G`) require `--force` (writes are large).
432
+ - `remove` (aliases `rm`, `delete`) removes one matched entry. The positional
433
+ target and optional `--match` select the record; group-wide deletion is not
434
+ implemented.
435
+
436
+ ### Record password channels
437
+
438
+ `add` and `set` take the record password one of two ways:
439
+
440
+ - `--password <secret>` — argv. Simple, but `ps` can show the value while the
441
+ process runs, and shell history can keep the command after it exits.
442
+ - `--password-stdin` — read from stdin, so the value never appears in argv. For
443
+ automation and harnesses that spawn centrs (`printf '%s' "$PW" | centrs
444
+ devices set <t> --password-stdin`, or `centrs devices set <t>
445
+ --password-stdin < pw.txt`).
446
+
447
+ `--password-stdin` rules:
448
+
449
+ - Exactly one trailing newline (`\n` or `\r\n`) is stripped, so `echo pw |`
450
+ works. Any other whitespace is part of the password.
451
+ - The rest must be valid UTF-8 with no leading byte-order mark; otherwise it
452
+ is `input/invalid-stdin`. The CDB stores record strings as UTF-8 and drops a
453
+ leading BOM on read, so either would store a different credential than the
454
+ bytes piped in.
455
+ - Empty input (nothing, or only that one newline) is `input/empty-stdin`.
456
+ Clearing a stored password stays a deliberate `--password ""`, never an empty
457
+ pipe.
458
+ - A terminal on stdin is refused with `usage/stdin-is-tty`; centrs does not
459
+ prompt.
460
+ - `--password` together with `--password-stdin` is `usage/conflicting-flags`.
461
+ There is no precedence order between them.
462
+ - Only `add` and `set` accept it; every other subcommand refuses it with
463
+ `input/invalid-command`.
464
+ - The value is never echoed in errors. Success envelopes are the same as with
465
+ `--password`.
466
+
467
+ There is no environment-variable channel for the record password:
468
+ `CENTRS_PASSWORD` is the dial credential, not a CDB field.
469
+
470
+ ### Record password in output
471
+
472
+ `show`, `add` and `set` withhold the stored record password by default, in text
473
+ and JSON (#551). Each entry always carries `passwordSet: boolean` (true when a
474
+ non-empty password is stored); text prints `password: (set, hidden)` or
475
+ `(not set)`. `--show-password` returns the cleartext `password` on those three
476
+ subcommands; every other subcommand refuses it with `input/invalid-command`.
477
+ The `--explain` record's password field reads `<redacted>` without the flag.
478
+ When an unknown-tcode `rawTail` comes before any decoded password field, the
479
+ tail may hold the password, so without the flag its `value` reads
480
+ `<redacted: N bytes, may contain password>` instead of the bytes. Such a
481
+ record decodes to an empty password, so `passwordSet` is `false` (it reports
482
+ the password centrs decoded and would dial with); the `cdb/unknown-field`
483
+ warning then carries `context.mayContainPassword: true`.
484
+ There is no encryption gate: the opt-in works the same on an open or encrypted
485
+ CDB. `passwordSet` is not the WinBox `savedPassword` ("keep password") flag.
486
+
487
+ The library matches the CLI: exported `showDevice` / `addDevice` / `setDevice`
488
+ redact by default and take `showPassword?: boolean` (default `false`); with
489
+ `showPassword: true` the entry type is `DevicesShowItemWithPassword`. MCP
490
+ (`centrs_devices`) never passes it, so MCP output never carries the password.
491
+
492
+ Dialing does not read this envelope. The resolver and the MCP allowlist gate
493
+ read the raw record through an internal lookup (`lookupDeviceRecord`, not
494
+ exported from the package root), so a CDB-credentialed dial still sends the
495
+ stored password. The public entry type has no `password` field, so a caller
496
+ that reached for the envelope instead fails to type-check.
415
497
 
416
498
  `--profile <name>` sets a named WinBox profile; `--profile-none` /
417
499
  `--profile-own` write the WinBox sentinels `<none>` / `<own>`. `profile` and
@@ -437,6 +519,54 @@ can edit it too. Precedence is per-field: args → env → device record →
437
519
  **MCP** it never widens the allowlist — an unregistered target is still
438
520
  rejected; `__default__` only fills missing creds for a registered device.
439
521
 
522
+ ## CDB encryption lifecycle (#558)
523
+
524
+ Start encrypted with `devices init --encrypt`, or start open for a lab and run
525
+ `devices encrypt` when moving to production. Both require a non-empty
526
+ `--cdb-password` / `CENTRS_CDB_PASSWORD`. A password alone does not change the
527
+ mode of initialization or ordinary writes. The library exports
528
+ `initCdb({ encrypt: true, cdbPassword, cdbFile })` and
529
+ `encryptCdb({ cdbPassword, cdbFile })`; both use the shared settings precedence.
530
+
531
+ Encrypted initialization keeps atomic no-clobber creation and mode `0600`.
532
+ Initialization returns `data.encrypted` for both newly created and existing
533
+ files. Competing encrypted initializers with different passwords fail closed:
534
+ the loser gets `cdb/decrypt-failed` when it cannot unlock the winner's file.
535
+ An existing open CDB refuses `init --encrypt`; conversion must be explicit.
536
+ An existing encrypted CDB is verified with the supplied password and left alone.
537
+ Conversion requires an existing readable CDB, preserves its records and unknown
538
+ fields, uses a fresh salt and atomic replacement, and keeps its file mode.
539
+ `data` reports `cdbFile`, `encrypted: true`, `changed`, `recordCount`, and the
540
+ new `backupPath` when conversion occurred. Default text output reports
541
+ `encrypted` or `verified encrypted ... (unchanged)`, the record count, and
542
+ the backup path when created, followed by the plaintext-backup warning.
543
+ Already-encrypted conversion verifies
544
+ the password and returns `changed: false` without rewriting or rotating it.
545
+
546
+ **Backups remain sensitive.** Conversion retains every existing backup and
547
+ creates a plaintext snapshot of the original; it does not run backup retention.
548
+ The `cdb/plaintext-backups` warning accompanies conversion, including no-op runs:
549
+ only the active file is protected. Handle backups according to your own retention
550
+ policy; centrs neither encrypts nor deletes them during conversion. Subsequent
551
+ ordinary writes still use the normal five-backup retention below.
552
+ `--no-backup` (library: `skipBackup: true`) skips the new plaintext snapshot, so
553
+ no fresh plaintext copy is written; earlier backups are still retained and the
554
+ warning still applies to them. Without the snapshot, a forgotten password cannot
555
+ be recovered.
556
+
557
+ `init` and `encrypt` act on the CDB file, not on records: they accept only
558
+ `--cdb-file`, `--cdb-password`, `--format`/`--json`, plus `--encrypt` (`init`)
559
+ or `--no-backup` (`encrypt`). Any other flag is `input/invalid-command`
560
+ instead of being silently ignored. `devices init --help` and
561
+ `devices encrypt --help` show only those accepted flags; the same metadata
562
+ generates their sections in [`docs/CLI.md`](../../docs/CLI.md).
563
+
564
+ Close WinBox and other CDB writers before conversion. If they share this path,
565
+ they must reopen it using the CDB password; a stale writer can overwrite the
566
+ conversion. Atomic replacement does not provide concurrent-writer transactions
567
+ (#581). Password rotation and decryption are outside this slice. WinBox manual
568
+ interoperability verification remains open (see Residual risks).
569
+
440
570
  ## CDB write strategy
441
571
 
442
572
  All writes go through one helper (`writeWinBoxCdb`) that:
@@ -94,7 +94,8 @@ centrs devices show 192.0.2.5 --cdb-file $CDB --explain
94
94
  ```
95
95
 
96
96
  `data.record` is the raw `WinBoxCdbRecord` (tag/marker/tcode/value). Useful
97
- for diagnosing unknown-field preservation.
97
+ for diagnosing unknown-field preservation. The password field value reads
98
+ `<redacted>` unless `--show-password` is passed (example 60).
98
99
 
99
100
  ## groups
100
101
 
@@ -651,7 +652,7 @@ centrs devices add 192.0.2.1 --user robot-reader --cdb-file ./lab/winbox.cdb --j
651
652
  ```
652
653
 
653
654
  Creates missing parent directories and an empty open CDB with mode `0600`.
654
- `data` is `{ cdbFile, created: true, recordCount: 0 }`. The `add` then succeeds.
655
+ `data` is `{ cdbFile, created: true, encrypted: false, recordCount: 0 }`. The `add` then succeeds.
655
656
 
656
657
  ### 55. `init` never clobbers
657
658
 
@@ -660,8 +661,173 @@ centrs devices init --cdb-file ./lab/winbox.cdb --json
660
661
  ```
661
662
 
662
663
  On an existing, readable CDB: `ok: true`, `data.created: false`,
664
+ `data.encrypted` reports the actual encryption mode,
663
665
  `data.recordCount` is the current count, and a `cdb/file-exists` warning says
664
666
  nothing was written. As with `git init`, re-running is safe. An existing file that
665
667
  does not load fails with that load error (`cdb/parse-failed`,
666
668
  `cdb/password-required`) and is left untouched. A location that cannot be
667
669
  written fails with `cdb/create-failed`.
670
+
671
+ ### 56. Initialize encrypted from the start
672
+
673
+ ```bash
674
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices init --encrypt --cdb-file ./prod.cdb --json
675
+ ```
676
+
677
+ Creates a complete encrypted empty CDB with mode `0600`; subsequent loads require
678
+ that password. `data.encrypted` is `true`. Re-running verifies the password
679
+ and reports `created: false`, `encrypted: true`.
680
+ Test: `centrs devices encryption (#558)` in `test/integration/devices.test.ts`.
681
+
682
+ ### 57. Convert a lab CDB to encrypted
683
+
684
+ ```bash
685
+ centrs devices init --cdb-file ./lab.cdb
686
+ centrs devices add 192.0.2.1 --user reader --cdb-file ./lab.cdb
687
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices encrypt --cdb-file ./lab.cdb --json
688
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices list --cdb-file ./lab.cdb --json
689
+ ```
690
+
691
+ Conversion reports `encrypted: true`, `changed: true`, `recordCount: 1`, and
692
+ `backupPath`. Records survive a password-protected reload; loading without a
693
+ password fails. All backups remain, including the plaintext pre-conversion file;
694
+ `cdb/plaintext-backups` warns explicitly. Repeating with the same password returns
695
+ `changed: false`; a wrong password fails without writing. Without `--json`,
696
+ text reports conversion or verified unchanged, the record count, and the
697
+ plaintext snapshot path when created; the warning remains visible.
698
+ Test: `centrs devices encryption (#558)` in `test/integration/devices.test.ts`.
699
+
700
+ ### 58. Encryption intent fails closed
701
+
702
+ `init --encrypt` and `encrypt` refuse a missing or empty CDB password before
703
+ creating anything. `init --encrypt` refuses an existing open CDB without changing
704
+ it. `--encrypt` on other subcommands and positional targets on `encrypt` are
705
+ invalid commands. Record, selection, and provenance flags (`--user`, `--group`,
706
+ `--password`, `--via`, …) on `init`/`encrypt`, and `--no-backup` on anything but
707
+ `encrypt`, are `input/invalid-command` rather than silently ignored. Test:
708
+ `centrs devices encryption (#558)` in `test/integration/devices.test.ts`.
709
+
710
+ ### 59. Convert without a plaintext snapshot
711
+
712
+ ```bash
713
+ centrs devices init --cdb-file ./lab-fresh.cdb
714
+ centrs devices add 192.0.2.1 --user reader --cdb-file ./lab-fresh.cdb
715
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices encrypt --no-backup --cdb-file ./lab-fresh.cdb --json
716
+ ```
717
+
718
+ A fresh path, so it does not depend on example 57 having already converted
719
+ `./lab.cdb`. Conversion reports `changed: true`, `recordCount: 1`, and no
720
+ `backupPath`, and no new `.bak.*` file appears. The backup the `add` wrote
721
+ is retained, so
722
+ `cdb/plaintext-backups` still warns. Test: `centrs devices encryption (#558)`
723
+ in `test/integration/devices.test.ts`.
724
+
725
+ ## Record password in output
726
+
727
+ `show`, `add` and `set` withhold the stored record password by default, in text
728
+ and JSON and in the library envelopes (#551). Every entry carries
729
+ `passwordSet: boolean`; `--show-password` (library: `showPassword: true`) opts in.
730
+ `passwordSet` is not the WinBox `savedPassword` flag. MCP never returns the
731
+ password and has no opt-in.
732
+
733
+ ### 60. `show` withholds the password and reports `passwordSet`
734
+
735
+ ```bash
736
+ centrs devices show 192.0.2.5 --cdb-file $CDB --explain --json
737
+ centrs devices show 192.0.2.5 --cdb-file $CDB
738
+ ```
739
+
740
+ `data.entry` has `passwordSet: true` and no `password` key. The `--explain`
741
+ record's password field (tag 3) reads `<redacted>`. Text prints
742
+ `password: (set, hidden)`, or `(not set)` for an empty password.
743
+
744
+ ### 61. `--show-password` returns the password on `show`, `add` and `set`
745
+
746
+ ```bash
747
+ centrs devices show 192.0.2.5 --cdb-file $CDB --show-password --json
748
+ centrs devices add 198.51.100.57 --user writer --password pw57 --cdb-file $CDB --json
749
+ centrs devices set 198.51.100.57 --group lab --cdb-file $CDB --show-password --json
750
+ ```
751
+
752
+ `show` returns `data.entry.password: "secret"`. The `add` without the flag
753
+ returns `passwordSet: true` and no `password`; the `set` with it returns
754
+ `data.entry.password: "pw57"`.
755
+
756
+ ### 62. `--show-password` is refused outside `show` / `add` / `set`
757
+
758
+ ```bash
759
+ centrs devices list --cdb-file $CDB --show-password --json
760
+ ```
761
+
762
+ `input/invalid-command` with `error.context: { subcommand: "list", flag:
763
+ "--show-password" }`, the same refusal other subcommand-scoped flags use.
764
+
765
+ ## Record password on stdin
766
+
767
+ `--password-stdin` (#582) reads the record password from fd 0 so it never rides
768
+ argv. These run as subprocesses (`test/integration/cli-process.ts`), because
769
+ only a child process can be handed real stdin bytes. The newline, empty and TTY
770
+ rules are also unit-tested in `test/unit/devices-password-stdin.test.ts`.
771
+
772
+ ### 63. `add --password-stdin` stores the piped value
773
+
774
+ ```bash
775
+ printf 'piped pw\n' | centrs devices add 192.0.2.77 --user robot --password-stdin --cdb-file $CDB --json
776
+ ```
777
+
778
+ `ok: true`. The stored password is `piped pw`: one trailing newline is
779
+ stripped, the inner space is kept.
780
+
781
+ ### 64. `set --password-stdin` strips a trailing CRLF
782
+
783
+ ```bash
784
+ printf 'rotated\r\n' | centrs devices set 192.0.2.5 --password-stdin --cdb-file $CDB --json
785
+ ```
786
+
787
+ `ok: true`. The stored password is `rotated`; group and comment are unchanged.
788
+
789
+ ### 65. Empty stdin is refused
790
+
791
+ ```bash
792
+ printf '' | centrs devices set 192.0.2.5 --password-stdin --cdb-file $CDB --json
793
+ ```
794
+
795
+ Errors with `input/empty-stdin` (also for a lone `\n`). The CDB is not written
796
+ and no backup is made.
797
+
798
+ ### 66. `--password` with `--password-stdin` conflicts
799
+
800
+ ```bash
801
+ echo stdin-secret | centrs devices set 192.0.2.5 --password argv-secret --password-stdin --cdb-file $CDB --json
802
+ echo stdin-secret | centrs devices set 192.0.2.5 --password --password-stdin --cdb-file $CDB --json
803
+ ```
804
+
805
+ Both error with `usage/conflicting-flags`. Neither value appears in the error;
806
+ `--password` cannot consume `--password-stdin` as a literal credential.
807
+
808
+ ### 67. Non-UTF-8 or BOM-prefixed stdin is refused
809
+
810
+ ```bash
811
+ printf 'pw\377\n' | centrs devices set 192.0.2.5 --password-stdin --cdb-file $CDB --json
812
+ printf '\357\273\277pw\n' | centrs devices set 192.0.2.5 --password-stdin --cdb-file $CDB --json
813
+ ```
814
+
815
+ Both error with `input/invalid-stdin`: the first is not valid UTF-8, the second
816
+ starts with a byte-order mark. The bytes are not echoed, the CDB is not written
817
+ and no backup is made.
818
+
819
+ ## File-command help
820
+
821
+ ### 68. `init` and `encrypt` show only their accepted flags
822
+
823
+ ```bash
824
+ centrs devices init --help
825
+ centrs devices encrypt --help
826
+ ```
827
+
828
+ Both exit 0 without reading or writing a CDB. Each shows its own usage, the
829
+ CDB path/password and output flags, plus `--encrypt` for `init` or
830
+ `--no-backup` for `encrypt`. Record and selection flags are absent, including
831
+ `--user`, `--password-stdin`, `--show-password`, `--group` and `--match`.
832
+ Test: `devices file-command help is scoped without opening a CDB` in
833
+ `test/integration/cli-smoke.test.ts` (network-free).
@@ -146,8 +146,8 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
146
146
  all-strings). Unlike `jo`-style KV builders, there is no per-value
147
147
  type-forcing escape, because the device, not centrs, owns value typing.
148
148
  - On an empty-CDB resolution failure, execute (like retrieve) emits the
149
- `tip/no-devices` tip, steering toward `centrs devices discover` /
150
- `centrs settings`.
149
+ `tip/no-devices` tip, steering toward `centrs discover --save` /
150
+ `centrs devices add`.
151
151
  - Protocol surfaces are native API, REST, mac-telnet, and SSH. RoMON and
152
152
  WinBox Terminal are later. SNMP is retrieve-only and must reject execute.
153
153
  - For a MAC target not resolved from CDB, auto-selection defaults to
@@ -529,6 +529,17 @@ How it is wired:
529
529
  but rejects the MD5 proof, so centrs advertises MTWEI (`src/protocols/mtwei.ts`)
530
530
  by default. `END_AUTH` ≠ success (a failed login emits `END_AUTH` then "Login
531
531
  failed" + `END`) → `transport/auth-failed`.
532
+ - **MTWEI hash encoding (grounded, #565):** RouterOS hashes shared `z` and the
533
+ REDP1 loop seed minimal-length; `haakonnessjoen/MAC-Telnet`'s `mtwei.c` pads
534
+ both to 32 bytes. When they differ, the padded proof fails. On CHR 7.25 over
535
+ the L2 harness (2026-10-31): short-`z` logins accepted the minimal proof 8 of
536
+ 8 and rejected the padded proof 1 of 1 (about 700 logins). The `z` edge hits
537
+ about 1 in 256 logins. The REDP1 edge depends on the salt RouterOS generates
538
+ at each password change, about 1 in 317 changes; it fails every login until
539
+ the next change. A password-rotation scan over raw handshakes to PASSSALT
540
+ found divergent salts, and on one the minimal proof was accepted 3 of 3 while
541
+ the padded proof was rejected 3 of 3, all with a clean 32-byte `z` to isolate
542
+ the edge. The shared core is `src/protocols/ec-srp5.ts`.
532
543
  - **Console quirks to know:** a successful write **prints no output** (no `.id`,
533
544
  unlike REST/native — `data.ret` is empty on a successful add); and there is a
534
545
  ~10s terminal-negotiation stall on every login (a latency cost, not a