@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
package/src/devices.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  decryptWinBoxCdb,
20
20
  type EncryptedWinBoxCdbFile,
21
21
  encodeOpenWinBoxCdb,
22
+ encryptWinBoxCdb,
22
23
  parseWinBoxCdb,
23
24
  type WinBoxCdbEntry,
24
25
  type WinBoxCdbField,
@@ -93,8 +94,16 @@ export interface DevicesListItem {
93
94
  location?: DeviceLocation;
94
95
  }
95
96
 
97
+ /**
98
+ * A CDB record as `devices show` / `add` / `set` return it. The record password
99
+ * is withheld by default (#551): `passwordSet` says whether one is stored, and
100
+ * only {@link DevicesShowItemWithPassword} (the `showPassword: true` opt-in)
101
+ * carries the value. Not to be confused with `savedPassword`, the WinBox
102
+ * "keep password" flag.
103
+ */
96
104
  export interface DevicesShowItem extends DevicesListItem {
97
- password: string;
105
+ /** True when the record stores a non-empty password. Always present. */
106
+ passwordSet: boolean;
98
107
  comment: string;
99
108
  commentMirror: string;
100
109
  profile: string;
@@ -104,6 +113,11 @@ export interface DevicesShowItem extends DevicesListItem {
104
113
  flags: number;
105
114
  }
106
115
 
116
+ /** {@link DevicesShowItem} plus the cleartext record password (`showPassword: true`). */
117
+ export interface DevicesShowItemWithPassword extends DevicesShowItem {
118
+ password: string;
119
+ }
120
+
107
121
  export interface DevicesGroupSummary {
108
122
  group: string;
109
123
  members: number;
@@ -112,6 +126,7 @@ export interface DevicesGroupSummary {
112
126
 
113
127
  export type DevicesCommand =
114
128
  | "init"
129
+ | "encrypt"
115
130
  | "list"
116
131
  | "show"
117
132
  | "groups"
@@ -165,7 +180,7 @@ export interface LoadedCdb {
165
180
  entries: readonly WinBoxCdbEntry[];
166
181
  settings: DevicesSettings;
167
182
  warnings: readonly DevicesWarning[];
168
- /** True when the source file on disk was encrypted (writes are blocked). */
183
+ /** True when the source file on disk was encrypted. */
169
184
  encrypted: boolean;
170
185
  }
171
186
 
@@ -232,7 +247,7 @@ function isAlreadyExistsError(cause: unknown): boolean {
232
247
  }
233
248
 
234
249
  /**
235
- * Create an empty open CDB at `path` without clobbering an existing file.
250
+ * Create an empty CDB at `path` without clobbering an existing file.
236
251
  * The bytes are written to a private temp file first and then hard-linked
237
252
  * into place: `link` fails with EEXIST instead of replacing, and `path` never
238
253
  * exists half-written, so a concurrent creator that loses the race reads a
@@ -242,14 +257,20 @@ function isAlreadyExistsError(cause: unknown): boolean {
242
257
  * file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
243
258
  * local users instead of falling back to the process umask default.
244
259
  */
245
- async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
260
+ async function createEmptyCdbNoClobber(
261
+ path: string,
262
+ password?: string,
263
+ ): Promise<boolean> {
246
264
  await mkdir(dirname(path), { recursive: true });
247
265
  const tempPath = `${path}.init.${randomUUID()}`;
248
266
  const handle = await open(tempPath, "wx", 0o600);
249
267
  try {
250
268
  try {
251
269
  await handle.chmod(0o600);
252
- await handle.write(encodeOpenWinBoxCdb([]));
270
+ const bytes = encodeOpenWinBoxCdb([]);
271
+ await handle.write(
272
+ password === undefined ? bytes : encryptWinBoxCdb(bytes, password),
273
+ );
253
274
  await handle.sync();
254
275
  } finally {
255
276
  await handle.close();
@@ -270,11 +291,19 @@ export interface DevicesInitData {
270
291
  cdbFile: string;
271
292
  /** False when a readable CDB was already there; nothing was written. */
272
293
  created: boolean;
294
+ /** Actual mode of the newly created or verified existing CDB. */
295
+ encrypted: boolean;
273
296
  recordCount: number;
274
297
  }
275
298
 
299
+ export interface InitCdbOptions extends LoadCdbOptions {
300
+ /** Require encryption; an existing open CDB is refused, never converted. */
301
+ encrypt?: boolean;
302
+ }
303
+
276
304
  /**
277
- * `devices init` (#376): create an empty open CDB at the resolved path, the
305
+ * `devices init`: create an empty CDB (open by default, encrypted with
306
+ * `encrypt: true`) at the resolved path, the
278
307
  * explicit counterpart of the default path's first-run auto-create. Never
279
308
  * clobbers: like `git init`, re-running on an existing, readable CDB succeeds
280
309
  * with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
@@ -282,15 +311,29 @@ export interface DevicesInitData {
282
311
  * (not a CDB, or encrypted without a password) fails with the load error.
283
312
  */
284
313
  export async function initCdb(
285
- options: LoadCdbOptions,
314
+ options: InitCdbOptions,
286
315
  ): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
287
316
  const settings = resolveDevicesSettings(options);
288
317
  const cdbFile = settings.cdbFile.value;
318
+ if (options.encrypt) requireEncryptionPassword(settings);
289
319
  const existingResult = async () => {
290
320
  const existing = await loadCdb(options);
321
+ if (options.encrypt && !existing.encrypted) {
322
+ throw new CentrsError({
323
+ code: "input/invalid-command",
324
+ summary: `An open CDB already exists at ${cdbFile}; encrypted initialization wrote nothing.`,
325
+ remediation: `Run \`centrs devices encrypt --cdb-file ${cdbFile}\` to convert the existing CDB explicitly.`,
326
+ context: { cdbFile },
327
+ });
328
+ }
291
329
  return {
292
330
  ok: true as const,
293
- data: { cdbFile, created: false, recordCount: existing.entries.length },
331
+ data: {
332
+ cdbFile,
333
+ created: false,
334
+ encrypted: existing.encrypted,
335
+ recordCount: existing.entries.length,
336
+ },
294
337
  warnings: [
295
338
  ...existing.warnings,
296
339
  {
@@ -310,7 +353,10 @@ export async function initCdb(
310
353
  }
311
354
  let created: boolean;
312
355
  try {
313
- created = await createEmptyCdbNoClobber(cdbFile);
356
+ created = await createEmptyCdbNoClobber(
357
+ cdbFile,
358
+ options.encrypt ? settings.cdbPassword.value : undefined,
359
+ );
314
360
  } catch (cause) {
315
361
  throw new CentrsError({
316
362
  code: "cdb/create-failed",
@@ -326,13 +372,113 @@ export async function initCdb(
326
372
  }
327
373
  return {
328
374
  ok: true,
329
- data: { cdbFile, created: true, recordCount: 0 },
375
+ data: {
376
+ cdbFile,
377
+ created: true,
378
+ encrypted: options.encrypt === true,
379
+ recordCount: 0,
380
+ },
330
381
  warnings: [],
331
382
  tips: [],
332
383
  meta: devicesMeta("init", settings),
333
384
  };
334
385
  }
335
386
 
387
+ function requireEncryptionPassword(settings: DevicesSettings): void {
388
+ if (
389
+ !settings.cdbPassword.provided ||
390
+ settings.cdbPassword.value.length === 0
391
+ ) {
392
+ throw new CentrsError({
393
+ code: "cdb/password-required",
394
+ summary: "CDB encryption requires a non-empty password.",
395
+ remediation:
396
+ "Pass --cdb-password or set CENTRS_CDB_PASSWORD to a non-empty CDB password.",
397
+ });
398
+ }
399
+ }
400
+
401
+ export interface EncryptCdbOptions extends LoadCdbOptions {
402
+ /**
403
+ * Skip the plaintext pre-conversion snapshot. Existing backups are still
404
+ * retained untouched; without the snapshot a forgotten password is final.
405
+ */
406
+ skipBackup?: boolean;
407
+ }
408
+
409
+ export interface DevicesEncryptData {
410
+ cdbFile: string;
411
+ encrypted: true;
412
+ /** False when the CDB was already encrypted and the password verified. */
413
+ changed: boolean;
414
+ recordCount: number;
415
+ /** Plaintext pre-conversion snapshot; absent when unchanged or `skipBackup`. */
416
+ backupPath?: string;
417
+ }
418
+
419
+ /**
420
+ * Explicitly convert an existing open CDB with a fresh salt and atomic replacement.
421
+ * Retain all backups: converting the active file does not protect old plaintext.
422
+ * Close WinBox and other writers first; this is not a cross-process transaction (#581).
423
+ * Already-encrypted files are verified with the supplied password and left untouched.
424
+ * `skipBackup` omits the new plaintext snapshot; it never deletes older backups.
425
+ */
426
+ export async function encryptCdb(
427
+ options: EncryptCdbOptions,
428
+ ): Promise<CentrsSuccessEnvelope<DevicesEncryptData, DevicesOperationMeta>> {
429
+ const settings = resolveDevicesSettings(options);
430
+ requireEncryptionPassword(settings);
431
+ const cdbFile = settings.cdbFile.value;
432
+ // Pin even the default path so conversion never implicitly creates a file.
433
+ const cdb = await loadCdb({ ...options, cdbFile });
434
+ let backupPath: string | undefined;
435
+ if (!cdb.encrypted) {
436
+ try {
437
+ const written = await writeWinBoxCdb(
438
+ cdbFile,
439
+ cdb.entries.map((entry) => entry.record),
440
+ {
441
+ encryptWith: { password: settings.cdbPassword.value },
442
+ backupRetention: -1,
443
+ skipBackup: options.skipBackup === true,
444
+ },
445
+ );
446
+ backupPath = written.backupPath;
447
+ } catch (cause) {
448
+ throw new CentrsError({
449
+ code: "cdb/encrypt-failed",
450
+ summary: `Could not encrypt the CDB at ${cdbFile}.`,
451
+ remediation:
452
+ "Check directory permissions and available disk space; retain the original and any backups before retrying.",
453
+ context: { cdbFile },
454
+ cause,
455
+ });
456
+ }
457
+ }
458
+ return {
459
+ ok: true,
460
+ data: {
461
+ cdbFile,
462
+ encrypted: true,
463
+ changed: !cdb.encrypted,
464
+ recordCount: cdb.entries.length,
465
+ ...(backupPath ? { backupPath } : {}),
466
+ },
467
+ warnings: [
468
+ ...cdb.warnings.filter(
469
+ (warning) => warning.code !== "cdb/password-not-needed",
470
+ ),
471
+ {
472
+ code: "cdb/plaintext-backups",
473
+ message:
474
+ "Backups may contain plaintext credentials. Encryption protects only the active CDB; no backups were deleted or encrypted.",
475
+ },
476
+ ],
477
+ tips: [],
478
+ meta: devicesMeta("encrypt", settings),
479
+ };
480
+ }
481
+
336
482
  export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
337
483
  const settings = resolveDevicesSettings(options);
338
484
  const warnings: DevicesWarning[] = [];
@@ -481,10 +627,19 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
481
627
  for (const entry of entries) {
482
628
  const tags = unknownTcodeTags(entry.record);
483
629
  if (tags.length > 0) {
630
+ // `passwordSet` reports the password centrs decoded and would dial
631
+ // with. When the undecoded tail may hold one, say so here rather than
632
+ // let `passwordSet: false` read as certain.
633
+ const mayContainPassword =
634
+ passwordBearingRawTailIndex(entry.record) !== -1;
484
635
  warnings.push({
485
636
  code: "cdb/unknown-field",
486
- message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.`,
487
- context: { target: entry.target, tags },
637
+ message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.${mayContainPassword ? " The undecoded bytes may contain the record password, so passwordSet may under-report it." : ""}`,
638
+ context: {
639
+ target: entry.target,
640
+ tags,
641
+ ...(mayContainPassword ? { mayContainPassword: true } : {}),
642
+ },
488
643
  });
489
644
  }
490
645
  }
@@ -661,13 +816,24 @@ export interface ShowDeviceArgs {
661
816
  */
662
817
  match?: string;
663
818
  env?: Record<string, string | undefined>;
819
+ /**
820
+ * Return the record password in cleartext (`--show-password`). Default
821
+ * `false`: the entry carries `passwordSet` and no `password`, and the
822
+ * `explain` record has its password field value replaced.
823
+ */
824
+ showPassword?: boolean;
664
825
  }
665
826
 
666
- export interface DevicesShowEnvelopeData {
667
- entry: DevicesShowItem;
827
+ export interface DevicesShowEnvelopeData<
828
+ Entry extends DevicesShowItem = DevicesShowItem,
829
+ > {
830
+ entry: Entry;
668
831
  record?: WinBoxCdbRecord;
669
832
  }
670
833
 
834
+ /** Placeholder written over a withheld password in an `explain` record. */
835
+ export const REDACTED_PASSWORD_VALUE = "<redacted>";
836
+
671
837
  /**
672
838
  * Canonical form for `<router>` comparison: a MAC is normalized (case /
673
839
  * separator / zero-pad insensitive via {@link normalizeMac}); everything else is
@@ -798,28 +964,150 @@ function describeMatches(matches: readonly EntryMatch[]): {
798
964
  }));
799
965
  }
800
966
 
967
+ /**
968
+ * The raw CDB record `<router>` resolves to, password included. INTERNAL: for
969
+ * callers that dial or gate on the record (the resolver, the MCP allowlist),
970
+ * never for output. It is deliberately absent from `src/index.ts`; the public
971
+ * {@link showDevice} envelope withholds the password by default (#551).
972
+ */
973
+ export interface DeviceRecordLookup {
974
+ entry: WinBoxCdbEntry;
975
+ cdbRecordIndex: number;
976
+ /** CDB load warnings plus the record's comment-kv / `via` warnings. */
977
+ warnings: DevicesWarning[];
978
+ tips: Tip[];
979
+ target: EnvelopeTargetMeta;
980
+ }
981
+
982
+ /**
983
+ * Resolve `<router>` to its raw CDB record (see {@link DeviceRecordLookup}).
984
+ * Same matcher, `--match` selection and errors as {@link showDevice}.
985
+ */
986
+ export function lookupDeviceRecord(
987
+ args: Omit<ShowDeviceArgs, "explain" | "showPassword">,
988
+ ): DeviceRecordLookup {
989
+ const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
990
+ const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
991
+ return {
992
+ entry: match.entry,
993
+ cdbRecordIndex: match.index,
994
+ warnings: [...args.cdb.warnings, ...resolved.warnings],
995
+ tips: credentialsMissingTips(match.entry, args.cdb.entries),
996
+ target: resolved.target,
997
+ };
998
+ }
999
+
1000
+ export function showDevice(
1001
+ args: ShowDeviceArgs & { showPassword: true },
1002
+ ): CentrsSuccessEnvelope<
1003
+ DevicesShowEnvelopeData<DevicesShowItemWithPassword>,
1004
+ DevicesOperationMeta
1005
+ >;
1006
+ export function showDevice(
1007
+ args: ShowDeviceArgs,
1008
+ ): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta>;
801
1009
  export function showDevice(
802
1010
  args: ShowDeviceArgs,
803
1011
  ): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta> {
804
- const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
1012
+ const found = lookupDeviceRecord(args);
1013
+ const showPassword = args.showPassword === true;
805
1014
  const data: DevicesShowEnvelopeData = {
806
- entry: entryToShowItem(match.entry, match.index),
1015
+ entry: projectShowItem(
1016
+ entryToShowItem(found.entry, found.cdbRecordIndex),
1017
+ showPassword,
1018
+ ),
807
1019
  };
808
1020
  if (args.explain) {
809
- data.record = match.entry.record;
1021
+ data.record = showPassword
1022
+ ? found.entry.record
1023
+ : redactRecordPassword(found.entry.record);
810
1024
  }
811
1025
 
812
- const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
813
-
814
1026
  return {
815
1027
  ok: true,
816
1028
  data,
817
- warnings: [...args.cdb.warnings, ...resolved.warnings],
818
- tips: credentialsMissingTips(match.entry, args.cdb.entries),
819
- meta: devicesMeta("show", args.cdb.settings, resolved.target),
1029
+ warnings: found.warnings,
1030
+ tips: found.tips,
1031
+ meta: devicesMeta("show", args.cdb.settings, found.target),
1032
+ };
1033
+ }
1034
+
1035
+ /**
1036
+ * The shared output redaction for a device entry (#551): drop `password`, keep
1037
+ * `passwordSet`. The CLI, the library envelopes and MCP all return entries
1038
+ * through this, so they withhold the secret the same way.
1039
+ */
1040
+ export function redactDeviceEntry(
1041
+ entry: DevicesShowItemWithPassword,
1042
+ ): DevicesShowItem {
1043
+ const { password, ...rest } = entry;
1044
+ return { ...rest, passwordSet: password.length > 0 };
1045
+ }
1046
+
1047
+ function projectShowItem(
1048
+ entry: DevicesShowItemWithPassword,
1049
+ showPassword: boolean,
1050
+ ): DevicesShowItem {
1051
+ return showPassword ? entry : redactDeviceEntry(entry);
1052
+ }
1053
+
1054
+ /**
1055
+ * Replace the password field value in an `explain` record. The record is a
1056
+ * debugging view of the CDB bytes; it must not become a side door around the
1057
+ * entry redaction.
1058
+ */
1059
+ function redactRecordPassword(record: WinBoxCdbRecord): WinBoxCdbRecord {
1060
+ const tailIndex = passwordBearingRawTailIndex(record);
1061
+ if (tailIndex === -1 && !record.fields.some(isPasswordField)) {
1062
+ return record;
1063
+ }
1064
+ return {
1065
+ ...record,
1066
+ fields: record.fields.map((field, index) => {
1067
+ if (index === tailIndex) {
1068
+ const length =
1069
+ field.value instanceof Uint8Array ? field.value.length : 0;
1070
+ return { ...field, value: redactedRawTailValue(length) };
1071
+ }
1072
+ return isPasswordField(field)
1073
+ ? { ...field, value: REDACTED_PASSWORD_VALUE }
1074
+ : field;
1075
+ }),
820
1076
  };
821
1077
  }
822
1078
 
1079
+ /**
1080
+ * Index of a `rawTail` field whose bytes may hold the password, or -1. The
1081
+ * decoder folds everything after an unknown tcode into one opaque tail, so
1082
+ * when no decoded password field (tag 3) precedes the tail, the password — if
1083
+ * the record has one — is somewhere inside those bytes (or the tail is tag 3
1084
+ * itself).
1085
+ */
1086
+ function passwordBearingRawTailIndex(record: WinBoxCdbRecord): number {
1087
+ for (const [index, field] of record.fields.entries()) {
1088
+ if (field.rawTail) {
1089
+ return index;
1090
+ }
1091
+ if (field.tag === winBoxCdbFieldTag.password) {
1092
+ return -1;
1093
+ }
1094
+ }
1095
+ return -1;
1096
+ }
1097
+
1098
+ function redactedRawTailValue(length: number): string {
1099
+ return `<redacted: ${length} bytes, may contain password>`;
1100
+ }
1101
+
1102
+ function isPasswordField(field: WinBoxCdbField): boolean {
1103
+ return (
1104
+ field.tag === winBoxCdbFieldTag.password &&
1105
+ !field.rawTail &&
1106
+ typeof field.value === "string" &&
1107
+ field.value.length > 0
1108
+ );
1109
+ }
1110
+
823
1111
  function entryToListItem(
824
1112
  entry: WinBoxCdbEntry,
825
1113
  cdbRecordIndex: number,
@@ -873,7 +1161,7 @@ interface ResolvedDeviceTargetMeta {
873
1161
  function resolveDeviceShowTargetMeta(
874
1162
  entry: WinBoxCdbEntry,
875
1163
  cdbRecordIndex: number,
876
- args: ShowDeviceArgs,
1164
+ args: Pick<ShowDeviceArgs, "target" | "via" | "env">,
877
1165
  ): ResolvedDeviceTargetMeta {
878
1166
  const parsed = parseCommentKv(entry.comment);
879
1167
  const warnings: DevicesWarning[] = parsed.warnings.map((warning) => ({
@@ -989,14 +1277,15 @@ function parseProtocolOption(
989
1277
  function entryToShowItem(
990
1278
  entry: WinBoxCdbEntry,
991
1279
  cdbRecordIndex: number,
992
- ): DevicesShowItem {
993
- const item: DevicesShowItem = {
1280
+ ): DevicesShowItemWithPassword {
1281
+ const item: DevicesShowItemWithPassword = {
994
1282
  target: entry.target,
995
1283
  recordType: entry.recordType,
996
1284
  recordTypeName: recordTypeName(entry.recordType),
997
1285
  group: entry.group,
998
1286
  user: entry.user,
999
1287
  password: entry.password,
1288
+ passwordSet: entry.password.length > 0,
1000
1289
  session: entry.session,
1001
1290
  comment: entry.comment,
1002
1291
  commentMirror: entry.commentMirror,
@@ -1067,7 +1356,9 @@ export function listGroups(
1067
1356
  * resulting record (absent for `remove`); `preservedUnknownTags` lists any
1068
1357
  * unknown/`rawTail` field tags carried over verbatim from the prior record.
1069
1358
  */
1070
- export interface DevicesMutationData {
1359
+ export interface DevicesMutationData<
1360
+ Entry extends DevicesShowItem = DevicesShowItem,
1361
+ > {
1071
1362
  action: "add" | "edit" | "set" | "remove";
1072
1363
  target: string;
1073
1364
  cdbRecordIndex: number;
@@ -1076,13 +1367,13 @@ export interface DevicesMutationData {
1076
1367
  backupPath?: string;
1077
1368
  prunedBackups: readonly string[];
1078
1369
  preservedUnknownTags?: readonly number[];
1079
- entry?: DevicesShowItem;
1370
+ /** The resulting record; the password is withheld unless `showPassword`. */
1371
+ entry?: Entry;
1080
1372
  }
1081
1373
 
1082
- export type DevicesMutationEnvelope = CentrsSuccessEnvelope<
1083
- DevicesMutationData,
1084
- DevicesOperationMeta
1085
- >;
1374
+ export type DevicesMutationEnvelope<
1375
+ Entry extends DevicesShowItem = DevicesShowItem,
1376
+ > = CentrsSuccessEnvelope<DevicesMutationData<Entry>, DevicesOperationMeta>;
1086
1377
 
1087
1378
  const KNOWN_FIELD_TAGS = new Set<number>(Object.values(winBoxCdbFieldTag));
1088
1379
 
@@ -1450,8 +1741,16 @@ export interface AddDeviceArgs {
1450
1741
  force?: boolean;
1451
1742
  strict?: boolean;
1452
1743
  writeOptions?: WriteWinBoxCdbOptions;
1744
+ /** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
1745
+ showPassword?: boolean;
1453
1746
  }
1454
1747
 
1748
+ export function addDevice(
1749
+ args: AddDeviceArgs & { showPassword: true },
1750
+ ): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
1751
+ export function addDevice(
1752
+ args: AddDeviceArgs,
1753
+ ): Promise<DevicesMutationEnvelope>;
1455
1754
  export async function addDevice(
1456
1755
  args: AddDeviceArgs,
1457
1756
  ): Promise<DevicesMutationEnvelope> {
@@ -1532,7 +1831,10 @@ export async function addDevice(
1532
1831
  args.target,
1533
1832
  cdbRecordIndex,
1534
1833
  existing !== undefined,
1535
- entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
1834
+ projectShowItem(
1835
+ entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
1836
+ args.showPassword === true,
1837
+ ),
1536
1838
  preserved,
1537
1839
  warnings,
1538
1840
  );
@@ -1553,6 +1855,8 @@ export interface SetDeviceArgs {
1553
1855
  savedPassword?: boolean;
1554
1856
  strict?: boolean;
1555
1857
  writeOptions?: WriteWinBoxCdbOptions;
1858
+ /** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
1859
+ showPassword?: boolean;
1556
1860
  }
1557
1861
 
1558
1862
  /**
@@ -1562,6 +1866,12 @@ export interface SetDeviceArgs {
1562
1866
  * {@link requireSingleMatch}; a missing target throws `cdb/not-found-target`.
1563
1867
  * Unchanged fields and the comment's free-form prose are preserved verbatim.
1564
1868
  */
1869
+ export function setDevice(
1870
+ args: SetDeviceArgs & { showPassword: true },
1871
+ ): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
1872
+ export function setDevice(
1873
+ args: SetDeviceArgs,
1874
+ ): Promise<DevicesMutationEnvelope>;
1565
1875
  export async function setDevice(
1566
1876
  args: SetDeviceArgs,
1567
1877
  ): Promise<DevicesMutationEnvelope> {
@@ -1638,7 +1948,10 @@ export async function setDevice(
1638
1948
  args.target,
1639
1949
  match.index,
1640
1950
  true,
1641
- entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
1951
+ projectShowItem(
1952
+ entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
1953
+ args.showPassword === true,
1954
+ ),
1642
1955
  preserved,
1643
1956
  warnings,
1644
1957
  );
@@ -1859,6 +2172,18 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
1859
2172
  ? `created ${data.cdbFile}`
1860
2173
  : `exists ${data.cdbFile} (${data.recordCount} record(s))`,
1861
2174
  );
2175
+ lines.push(`encrypted: ${data.encrypted}`);
2176
+ break;
2177
+ }
2178
+ case "encrypt": {
2179
+ const data = envelope.data as DevicesEncryptData;
2180
+ lines.push(
2181
+ data.changed
2182
+ ? `encrypted ${data.cdbFile}`
2183
+ : `verified encrypted ${data.cdbFile} (unchanged)`,
2184
+ );
2185
+ lines.push(`records: ${data.recordCount}`);
2186
+ if (data.backupPath) lines.push(`backup: ${data.backupPath}`);
1862
2187
  break;
1863
2188
  }
1864
2189
  case "list":
@@ -1923,6 +2248,7 @@ function renderMutationText(lines: string[], data: DevicesMutationData): void {
1923
2248
  }
1924
2249
  if (data.entry) {
1925
2250
  lines.push(`user: ${data.entry.user || "-"}`);
2251
+ lines.push(passwordLine(data.entry));
1926
2252
  lines.push(`group: ${data.entry.group || "-"}`);
1927
2253
  lines.push(`comment: ${data.entry.comment || "-"}`);
1928
2254
  }
@@ -1959,6 +2285,7 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
1959
2285
  lines.push(`record-type: ${entry.recordTypeName} (${entry.recordType})`);
1960
2286
  lines.push(`cdb-index: ${entry.cdbRecordIndex}`);
1961
2287
  lines.push(`user: ${entry.user || "-"}`);
2288
+ lines.push(passwordLine(entry));
1962
2289
  lines.push(`saved-password: ${entry.savedPassword ? "yes" : "no"}`);
1963
2290
  lines.push(`group: ${entry.group || "-"}`);
1964
2291
  lines.push(`profile: ${entry.profile || "-"}`);
@@ -1984,9 +2311,27 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
1984
2311
  }
1985
2312
  }
1986
2313
 
2314
+ /**
2315
+ * The `password:` text line. The cleartext value appears only when the entry
2316
+ * carries it (`--show-password`); otherwise it reads `(set, hidden)` or
2317
+ * `(not set)`.
2318
+ */
2319
+ function passwordLine(entry: DevicesShowItem): string {
2320
+ const shown = (entry as Partial<DevicesShowItemWithPassword>).password;
2321
+ if (typeof shown === "string") {
2322
+ // An empty password reads `(not set)`, never `-`, which is a valid password.
2323
+ return `password: ${shown === "" ? "(not set)" : shown}`;
2324
+ }
2325
+ return `password: ${entry.passwordSet ? "(set, hidden)" : "(not set)"}`;
2326
+ }
2327
+
1987
2328
  function describeField(field: WinBoxCdbField): string {
1988
2329
  const head = `tag=${field.tag} marker=0x${field.marker.toString(16).padStart(2, "0")} tcode=0x${field.tcode.toString(16).padStart(2, "0")}`;
1989
2330
  if (field.rawTail) {
2331
+ if (typeof field.value === "string") {
2332
+ // Withheld by `redactRecordPassword`: the tail may hold the password.
2333
+ return `${head} rawTail ${field.value}`;
2334
+ }
1990
2335
  const bytes = field.value instanceof Uint8Array ? field.value : null;
1991
2336
  const preview = bytes
1992
2337
  ? Array.from(bytes.subarray(0, 16))
@@ -60,6 +60,7 @@ import {
60
60
  } from "./execute.ts";
61
61
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
62
62
  import {
63
+ assertNoMatchInFanout,
63
64
  emptyWriteSelectionError,
64
65
  expandSelection,
65
66
  isDefaultRecordTarget,
@@ -206,6 +207,7 @@ export async function executeFanout(
206
207
  internals: ExecuteFanoutInternals = {},
207
208
  options: ExecuteFanoutOptions = {},
208
209
  ): Promise<ExecuteFanoutEnvelope> {
210
+ assertNoMatchInFanout(request.match);
209
211
  // Validate + canonicalize the command ONCE, before selection expansion: a bad
210
212
  // command shape is an outer error regardless of how many targets resolve, and
211
213
  // write-ness is target-independent.
package/src/execute.ts CHANGED
@@ -120,6 +120,12 @@ export interface ExecuteRequest {
120
120
  maxResultsBytes?: number;
121
121
  cdbFile?: string;
122
122
  cdbPassword?: string;
123
+ /**
124
+ * `--match` selector for a `<router>` several CDB records share
125
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
126
+ * `devices show --match` takes. Single-target only (#583).
127
+ */
128
+ match?: string;
123
129
  /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
124
130
  resolve?: string;
125
131
  stdinIsTty?: boolean;
@@ -367,6 +373,7 @@ export async function resolveExecuteRequest(
367
373
  targetInput: request.targetInput,
368
374
  cdbFile: request.cdbFile,
369
375
  cdbPassword: request.cdbPassword,
376
+ match: request.match,
370
377
  },
371
378
  env,
372
379
  config,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Block / scope-brace classification for `explain` (centrs canonicalizer).
3
3
  *
4
- * Ratified by the phase-0 lab, question Q2 (#185) and promoted from the
5
- * throwaway probe `.scratch/explain-lab-blocktree.ts`. Q2 asks: can an offline,
4
+ * Ratified by the phase-0 lab, question Q2 (#185); the lab probe it was
5
+ * promoted from is history (see #185), not a runnable method. Q2 asks: can an offline,
6
6
  * schema-free brace tracker tell a `{…}` SCOPE (`do=`/`else=`/`on-error=`, which
7
7
  * IL lowers to `name=;(evl …)`) from a `name={…}` LITERAL value (braces
8
8
  * stripped, body kept as RAW text, never lowered)? Descending into a literal