@tikoci/centrs 0.1.0

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 (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
package/src/devices.ts ADDED
@@ -0,0 +1,1717 @@
1
+ import { mkdir, open } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type {
5
+ CentrsEnvelope,
6
+ CentrsSuccessEnvelope,
7
+ EnvelopeMeta,
8
+ EnvelopeTargetMeta,
9
+ SettingSource,
10
+ SettingSourceKind,
11
+ Tip,
12
+ } from "./core/envelope.ts";
13
+ import { buildTip as tip } from "./core/envelope.ts";
14
+ import {
15
+ buildWinBoxCdbEntryRecord,
16
+ decodeWinBoxCdbEntries,
17
+ decodeWinBoxCdbEntry,
18
+ decryptWinBoxCdb,
19
+ type EncryptedWinBoxCdbFile,
20
+ encodeOpenWinBoxCdb,
21
+ parseWinBoxCdb,
22
+ type WinBoxCdbEntry,
23
+ type WinBoxCdbField,
24
+ type WinBoxCdbRecord,
25
+ WinBoxCdbWrongPasswordError,
26
+ winBoxCdbFieldTag,
27
+ winBoxCdbParseErrorContext,
28
+ winBoxCdbRecordType,
29
+ } from "./data/winbox-cdb.ts";
30
+ import {
31
+ type WriteWinBoxCdbOptions,
32
+ writeWinBoxCdb,
33
+ } from "./data/winbox-cdb-write.ts";
34
+ import { parseDeviceRecord } from "./devices-schema.ts";
35
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
36
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
37
+ import {
38
+ applyCommentKv,
39
+ type CommentKvUpdate,
40
+ commentKvAllowlist,
41
+ commentKvLookupKeys,
42
+ commentKvReservedKeys,
43
+ parseCommentKv,
44
+ } from "./resolver/comment-kv.ts";
45
+ import { normalizeMac } from "./resolver/mac.ts";
46
+
47
+ export type { SettingSource, SettingSourceKind };
48
+
49
+ export interface DevicesSettings {
50
+ cdbFile: { value: string; source: SettingSource };
51
+ cdbPassword: { value: string; source: SettingSource; provided: boolean };
52
+ }
53
+
54
+ export interface DevicesWarning {
55
+ code: string;
56
+ message: string;
57
+ context?: Record<string, unknown>;
58
+ }
59
+
60
+ export interface DevicesListItem {
61
+ target: string;
62
+ recordType: number;
63
+ recordTypeName: string;
64
+ group: string;
65
+ user: string;
66
+ cdbRecordIndex: number;
67
+ source?: string;
68
+ sources?: Record<string, SettingSource>;
69
+ }
70
+
71
+ export interface DevicesShowItem extends DevicesListItem {
72
+ password: string;
73
+ comment: string;
74
+ commentMirror: string;
75
+ profile: string;
76
+ session: string;
77
+ romonAgent: string;
78
+ savedPassword: boolean;
79
+ flags: number;
80
+ }
81
+
82
+ export interface DevicesGroupSummary {
83
+ group: string;
84
+ members: number;
85
+ memberEntries?: readonly { target: string; recordType: number }[];
86
+ }
87
+
88
+ export type DevicesCommand =
89
+ | "list"
90
+ | "show"
91
+ | "groups"
92
+ | "add"
93
+ | "edit"
94
+ | "set"
95
+ | "remove";
96
+
97
+ export interface DevicesOperationMeta {
98
+ command: DevicesCommand;
99
+ cdbFile: string;
100
+ cdbPasswordProvided: boolean;
101
+ }
102
+
103
+ export type DevicesEnvelope<Data> = CentrsEnvelope<Data, DevicesOperationMeta>;
104
+
105
+ function devicesMeta(
106
+ command: DevicesCommand,
107
+ settings: DevicesSettings,
108
+ target: EnvelopeTargetMeta = {},
109
+ ): EnvelopeMeta<DevicesOperationMeta> {
110
+ return {
111
+ target,
112
+ via: null,
113
+ settings: {
114
+ cdbFile: settings.cdbFile.source,
115
+ cdbPassword: settings.cdbPassword.source,
116
+ },
117
+ operation: {
118
+ command,
119
+ cdbFile: settings.cdbFile.value,
120
+ cdbPasswordProvided: settings.cdbPassword.provided,
121
+ },
122
+ };
123
+ }
124
+
125
+ export interface LoadCdbOptions {
126
+ cdbFile?: string;
127
+ cdbPassword?: string;
128
+ env?: Record<string, string | undefined>;
129
+ }
130
+
131
+ export interface LoadedCdb {
132
+ entries: readonly WinBoxCdbEntry[];
133
+ settings: DevicesSettings;
134
+ warnings: readonly DevicesWarning[];
135
+ /** True when the source file on disk was encrypted (writes are blocked). */
136
+ encrypted: boolean;
137
+ }
138
+
139
+ const DEFAULT_CDB_RELATIVE = ".config/tikoci/winbox.cdb";
140
+ const ENV_HOME = "HOME";
141
+ const ENV_CDB_FILE = "CENTRS_CDB_FILE";
142
+ const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
143
+
144
+ export function defaultCdbPath(
145
+ env?: Record<string, string | undefined>,
146
+ ): string {
147
+ const home = env?.[ENV_HOME] ?? homedir();
148
+ return join(home, DEFAULT_CDB_RELATIVE);
149
+ }
150
+
151
+ export function resolveDevicesSettings(
152
+ options: LoadCdbOptions,
153
+ ): DevicesSettings {
154
+ const env = options.env ?? {};
155
+ let cdbFileSource: SettingSource = { kind: "default", key: "default" };
156
+ let cdbFile = defaultCdbPath(env);
157
+ const envCdbFile = env[ENV_CDB_FILE];
158
+ if (envCdbFile) {
159
+ cdbFile = envCdbFile;
160
+ cdbFileSource = { kind: "env", key: ENV_CDB_FILE };
161
+ }
162
+ if (options.cdbFile !== undefined) {
163
+ cdbFile = options.cdbFile;
164
+ cdbFileSource = { kind: "cli", key: "--cdb-file" };
165
+ }
166
+
167
+ let cdbPassword = "";
168
+ let provided = false;
169
+ let cdbPasswordSource: SettingSource = { kind: "default", key: "default" };
170
+ const envCdbPassword = env[ENV_CDB_PASSWORD];
171
+ if (envCdbPassword !== undefined) {
172
+ cdbPassword = envCdbPassword;
173
+ provided = true;
174
+ cdbPasswordSource = { kind: "env", key: ENV_CDB_PASSWORD };
175
+ }
176
+ if (options.cdbPassword !== undefined) {
177
+ cdbPassword = options.cdbPassword;
178
+ provided = true;
179
+ cdbPasswordSource = { kind: "cli", key: "--cdb-password" };
180
+ }
181
+
182
+ return {
183
+ cdbFile: { value: cdbFile, source: cdbFileSource },
184
+ cdbPassword: { value: cdbPassword, source: cdbPasswordSource, provided },
185
+ };
186
+ }
187
+
188
+ function isAlreadyExistsError(cause: unknown): boolean {
189
+ return (
190
+ typeof cause === "object" &&
191
+ cause !== null &&
192
+ (cause as { code?: unknown }).code === "EEXIST"
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Create an empty open CDB at `path` without clobbering an existing file.
198
+ * Uses an exclusive-create open (`wx`) so a concurrent writer's CDB is never
199
+ * overwritten; the caller treats EEXIST as "another process created it".
200
+ */
201
+ async function createEmptyCdbNoClobber(path: string): Promise<void> {
202
+ await mkdir(dirname(path), { recursive: true });
203
+ const handle = await open(path, "wx");
204
+ try {
205
+ await handle.write(encodeOpenWinBoxCdb([]));
206
+ await handle.sync();
207
+ } finally {
208
+ await handle.close();
209
+ }
210
+ }
211
+
212
+ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
213
+ const settings = resolveDevicesSettings(options);
214
+ const warnings: DevicesWarning[] = [];
215
+
216
+ const file = Bun.file(settings.cdbFile.value);
217
+ if (!(await file.exists())) {
218
+ // At the default path with no explicit --cdb-file/env override, a missing
219
+ // CDB is the first-run case, not an error: create an empty open CDB so
220
+ // `devices`/identity commands work out of the box. An explicit missing
221
+ // path stays an error (typo protection).
222
+ if (settings.cdbFile.source.kind === "default") {
223
+ try {
224
+ await createEmptyCdbNoClobber(settings.cdbFile.value);
225
+ warnings.push({
226
+ code: "cdb/created",
227
+ message: `No CDB found at the default path; created an empty CDB at ${settings.cdbFile.value}.`,
228
+ });
229
+ } catch (cause) {
230
+ // EEXIST means another process won the race and created the CDB
231
+ // between our existence check and the exclusive open — that file
232
+ // is authoritative; fall through and read it instead of erroring.
233
+ if (!isAlreadyExistsError(cause)) {
234
+ throw new CentrsError({
235
+ code: "cdb/not-found",
236
+ summary: `CDB file not found and could not be created: ${settings.cdbFile.value}`,
237
+ remediation:
238
+ "Check directory permissions for ~/.config/tikoci, or pass --cdb-file PATH to a writable location.",
239
+ context: { cdbFile: settings.cdbFile.value },
240
+ cause,
241
+ });
242
+ }
243
+ }
244
+ } else {
245
+ throw new CentrsError({
246
+ code: "cdb/not-found",
247
+ summary: `CDB file not found: ${settings.cdbFile.value}`,
248
+ remediation:
249
+ "Pass --cdb-file PATH, set CENTRS_CDB_FILE, or place a CDB at ~/.config/tikoci/winbox.cdb.",
250
+ context: { cdbFile: settings.cdbFile.value },
251
+ });
252
+ }
253
+ }
254
+
255
+ const bytes = new Uint8Array(
256
+ await Bun.file(settings.cdbFile.value).arrayBuffer(),
257
+ );
258
+ let parsed: ReturnType<typeof parseWinBoxCdb>;
259
+ try {
260
+ parsed = parseWinBoxCdb(bytes);
261
+ } catch (cause) {
262
+ throw new CentrsError({
263
+ code: "cdb/parse-failed",
264
+ summary:
265
+ cause instanceof Error ? cause.message : "Failed to parse CDB file.",
266
+ remediation:
267
+ "Verify the file is a WinBox CDB and not truncated; restore from a backup if necessary.",
268
+ context: {
269
+ cdbFile: settings.cdbFile.value,
270
+ ...winBoxCdbParseErrorContext(cause),
271
+ },
272
+ cause,
273
+ });
274
+ }
275
+
276
+ let openBytes: Uint8Array;
277
+ if (parsed.mode === "open") {
278
+ if (settings.cdbPassword.provided) {
279
+ warnings.push({
280
+ code: "cdb/password-not-needed",
281
+ message:
282
+ "--cdb-password was provided but the CDB is not encrypted; the password was ignored.",
283
+ });
284
+ }
285
+ openBytes = bytes;
286
+ } else {
287
+ if (!settings.cdbPassword.provided) {
288
+ throw new CentrsError({
289
+ code: "cdb/password-required",
290
+ summary: "CDB is encrypted; --cdb-password is required.",
291
+ remediation:
292
+ "Pass --cdb-password or set CENTRS_CDB_PASSWORD to the CDB password.",
293
+ context: { cdbFile: settings.cdbFile.value },
294
+ });
295
+ }
296
+ try {
297
+ openBytes = decryptCdb(parsed, settings.cdbPassword.value);
298
+ } catch (cause) {
299
+ if (cause instanceof WinBoxCdbWrongPasswordError) {
300
+ throw new CentrsError({
301
+ code: "cdb/decrypt-failed",
302
+ summary:
303
+ "CDB decryption produced an invalid open-CDB magic; the password is likely wrong.",
304
+ remediation:
305
+ "Re-check --cdb-password or CENTRS_CDB_PASSWORD against the value used when the CDB was saved.",
306
+ context: { cdbFile: settings.cdbFile.value },
307
+ cause,
308
+ causeData: { reason: "open-magic-mismatch" },
309
+ });
310
+ }
311
+ throw new CentrsError({
312
+ code: "cdb/decrypt-failed",
313
+ summary:
314
+ cause instanceof Error
315
+ ? cause.message
316
+ : "Failed to decrypt CDB file.",
317
+ remediation:
318
+ "Verify --cdb-password is correct and the file is a WinBox-encrypted CDB.",
319
+ context: { cdbFile: settings.cdbFile.value },
320
+ cause,
321
+ });
322
+ }
323
+ }
324
+
325
+ let entries: WinBoxCdbEntry[];
326
+ try {
327
+ const openFile = parseWinBoxCdb(openBytes);
328
+ if (openFile.mode !== "open") {
329
+ throw new CentrsError({
330
+ code: "cdb/parse-failed",
331
+ summary: "Decrypted CDB did not parse as an open file.",
332
+ remediation:
333
+ "Re-check --cdb-password; the bytes after decryption are malformed.",
334
+ context: { cdbFile: settings.cdbFile.value },
335
+ });
336
+ }
337
+ entries = decodeWinBoxCdbEntries(openFile);
338
+ } catch (cause) {
339
+ if (cause instanceof CentrsError) {
340
+ throw cause;
341
+ }
342
+ throw new CentrsError({
343
+ code: "cdb/parse-failed",
344
+ summary:
345
+ cause instanceof Error
346
+ ? cause.message
347
+ : "Failed to decode the decrypted CDB.",
348
+ remediation:
349
+ "Re-check --cdb-password; the bytes after decryption are malformed.",
350
+ context: {
351
+ cdbFile: settings.cdbFile.value,
352
+ ...winBoxCdbParseErrorContext(cause),
353
+ },
354
+ cause,
355
+ });
356
+ }
357
+ for (const entry of entries) {
358
+ const tags = unknownTcodeTags(entry.record);
359
+ if (tags.length > 0) {
360
+ warnings.push({
361
+ code: "cdb/unknown-field",
362
+ message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.`,
363
+ context: { target: entry.target, tags },
364
+ });
365
+ }
366
+ }
367
+
368
+ return {
369
+ entries,
370
+ settings,
371
+ warnings,
372
+ encrypted: parsed.mode === "encrypted",
373
+ };
374
+ }
375
+
376
+ function decryptCdb(
377
+ encrypted: EncryptedWinBoxCdbFile,
378
+ password: string,
379
+ ): Uint8Array {
380
+ return decryptWinBoxCdb(encrypted, password);
381
+ }
382
+
383
+ function unknownTcodeTags(record: WinBoxCdbRecord): readonly number[] {
384
+ const tags: number[] = [];
385
+ for (const field of record.fields) {
386
+ if (field.rawTail) {
387
+ tags.push(field.tag);
388
+ }
389
+ }
390
+ return tags;
391
+ }
392
+
393
+ export function recordTypeName(recordType: number): string {
394
+ for (const [name, value] of Object.entries(winBoxCdbRecordType)) {
395
+ if (value === recordType) {
396
+ return name;
397
+ }
398
+ }
399
+ return `unknown(${recordType})`;
400
+ }
401
+
402
+ /** Reserved CDB target supplying fallback creds (mirrors the resolver constant). */
403
+ const DEFAULT_RECORD_TARGET = "__default__";
404
+
405
+ /** Tip for an empty registry — the first-run / no-records-yet pointer. */
406
+ function noDevicesTips(entries: readonly WinBoxCdbEntry[]): Tip[] {
407
+ if (entries.length > 0) {
408
+ return [];
409
+ }
410
+ return [
411
+ tip(
412
+ "tip/no-devices",
413
+ "The CDB has no device records yet.",
414
+ "Add one with `centrs devices add <target> --user … --password …`.",
415
+ ),
416
+ ];
417
+ }
418
+
419
+ /** Tip when a resolved device carries no usable stored credentials. */
420
+ function credentialsMissingTips(
421
+ entry: WinBoxCdbEntry,
422
+ entries: readonly WinBoxCdbEntry[],
423
+ ): Tip[] {
424
+ const hasDefault = entries.some((e) => e.target === DEFAULT_RECORD_TARGET);
425
+ if (entry.password !== "" || entry.savedPassword || hasDefault) {
426
+ return [];
427
+ }
428
+ return [
429
+ tip(
430
+ "tip/credentials-missing",
431
+ `Device "${entry.target}" has no stored password.`,
432
+ "Pass --password at call time, set it with `centrs devices set`, or add a `__default__` record for fallback credentials.",
433
+ ),
434
+ ];
435
+ }
436
+
437
+ export interface ListDevicesArgs {
438
+ cdb: LoadedCdb;
439
+ group?: string;
440
+ }
441
+
442
+ export function listDevices(
443
+ args: ListDevicesArgs,
444
+ ): CentrsSuccessEnvelope<readonly DevicesListItem[], DevicesOperationMeta> {
445
+ const warnings = [...args.cdb.warnings];
446
+ let entries = args.cdb.entries;
447
+ let indices = entries.map((_, index) => index);
448
+
449
+ if (args.group !== undefined) {
450
+ const wanted = args.group;
451
+ const filtered: { entry: WinBoxCdbEntry; index: number }[] = [];
452
+ for (let index = 0; index < entries.length; index += 1) {
453
+ const entry = entries[index];
454
+ if (entry && entry.group === wanted) {
455
+ filtered.push({ entry, index });
456
+ }
457
+ }
458
+ entries = filtered.map((item) => item.entry);
459
+ indices = filtered.map((item) => item.index);
460
+ if (filtered.length === 0) {
461
+ warnings.push({
462
+ code: "cdb/empty-group",
463
+ message: `No entries matched group "${wanted}".`,
464
+ context: { group: wanted },
465
+ });
466
+ }
467
+ }
468
+
469
+ const data: DevicesListItem[] = entries.map((entry, position) =>
470
+ entryToListItem(entry, indices[position] ?? -1),
471
+ );
472
+
473
+ return {
474
+ ok: true,
475
+ data,
476
+ warnings,
477
+ tips: noDevicesTips(args.cdb.entries),
478
+ meta: devicesMeta("list", args.cdb.settings),
479
+ };
480
+ }
481
+
482
+ export interface ShowDeviceArgs {
483
+ cdb: LoadedCdb;
484
+ target: string;
485
+ explain?: boolean;
486
+ via?: string;
487
+ /**
488
+ * Disambiguator when `<router>` matches more than one CDB entry. One of:
489
+ * `user=<name>`, `target=<addr>`, or a record-type token (a
490
+ * `winBoxCdbRecordType` name, e.g. `ipAdmin`/`ipUser`/`macTarget`). It cannot
491
+ * select between two entries that share every selectable attribute.
492
+ */
493
+ match?: string;
494
+ env?: Record<string, string | undefined>;
495
+ }
496
+
497
+ export interface DevicesShowEnvelopeData {
498
+ entry: DevicesShowItem;
499
+ record?: WinBoxCdbRecord;
500
+ }
501
+
502
+ /**
503
+ * Canonical form for `<router>` comparison: a MAC is normalized (case /
504
+ * separator / zero-pad insensitive via {@link normalizeMac}); everything else is
505
+ * compared verbatim after trimming. Used for both `target` and the comment
506
+ * lookup keys so `aa-b-cc-...` and `AA:0B:CC:...` resolve the same record.
507
+ */
508
+ function canonicalRouterKey(value: string): string {
509
+ const trimmed = value.trim();
510
+ return normalizeMac(trimmed) ?? trimmed;
511
+ }
512
+
513
+ /**
514
+ * Every identifier a record answers to: its `target` plus the `identity=` /
515
+ * `mac=` / `ip=` comment lookup keys, each canonicalized. See
516
+ * `commands/devices/README.md` (Identity model).
517
+ */
518
+ function entryRouterKeys(entry: WinBoxCdbEntry): string[] {
519
+ const keys = [canonicalRouterKey(entry.target)];
520
+ const { lookups } = parseCommentKv(entry.comment);
521
+ for (const value of [lookups.identity, lookups.mac, lookups.ip]) {
522
+ if (value !== undefined && value.trim().length > 0) {
523
+ keys.push(canonicalRouterKey(value));
524
+ }
525
+ }
526
+ return keys;
527
+ }
528
+
529
+ /**
530
+ * Find every CDB entry that `<router>` resolves to — by `target` or by an
531
+ * `identity=` / `mac=` / `ip=` comment lookup key. The candidate set feeds the
532
+ * ambiguity / `--match` selection in {@link showDevice} and the mutation path.
533
+ */
534
+ function matchRouter(
535
+ entries: readonly WinBoxCdbEntry[],
536
+ router: string,
537
+ ): EntryMatch[] {
538
+ const want = canonicalRouterKey(router);
539
+ const matches: EntryMatch[] = [];
540
+ for (let index = 0; index < entries.length; index += 1) {
541
+ const entry = entries[index];
542
+ if (entry && entryRouterKeys(entry).includes(want)) {
543
+ matches.push({ entry, index });
544
+ }
545
+ }
546
+ return matches;
547
+ }
548
+
549
+ interface RouterMatchSelector {
550
+ user?: string;
551
+ target?: string;
552
+ recordType?: number;
553
+ }
554
+
555
+ /**
556
+ * Parse a `--match` token into a selector. Forms: `user=<name>`,
557
+ * `target=<addr>`, or a bare {@link winBoxCdbRecordType} name (e.g. `ipUser`).
558
+ * An unrecognized key or bare token throws `input/invalid-match`.
559
+ */
560
+ function parseMatchSelector(
561
+ match: string,
562
+ contextTarget: string,
563
+ ): RouterMatchSelector {
564
+ const eq = match.indexOf("=");
565
+ if (eq > 0) {
566
+ const key = match.slice(0, eq);
567
+ const value = match.slice(eq + 1);
568
+ if (key === "user") {
569
+ return { user: value };
570
+ }
571
+ if (key === "target") {
572
+ return { target: canonicalRouterKey(value) };
573
+ }
574
+ throw new CentrsError({
575
+ code: "input/invalid-match",
576
+ summary: `--match key "${key}" is not supported.`,
577
+ remediation:
578
+ "Use --match user=<name>, --match target=<addr>, or a record-type token (e.g. ipAdmin / ipUser / macTarget).",
579
+ context: { target: contextTarget, match },
580
+ });
581
+ }
582
+ const recordType = recordTypeFromName(match);
583
+ if (recordType === undefined) {
584
+ throw new CentrsError({
585
+ code: "input/invalid-match",
586
+ summary: `--match "${match}" is not a known record type.`,
587
+ remediation: `Pass --match user=<name>, --match target=<addr>, or one of ${Object.keys(winBoxCdbRecordType).join(", ")}.`,
588
+ context: { target: contextTarget, match },
589
+ });
590
+ }
591
+ return { recordType };
592
+ }
593
+
594
+ function applyMatchSelector(
595
+ matches: readonly EntryMatch[],
596
+ selector: RouterMatchSelector,
597
+ ): EntryMatch[] {
598
+ return matches.filter(({ entry }) => {
599
+ if (selector.user !== undefined && entry.user !== selector.user) {
600
+ return false;
601
+ }
602
+ if (
603
+ selector.target !== undefined &&
604
+ canonicalRouterKey(entry.target) !== selector.target
605
+ ) {
606
+ return false;
607
+ }
608
+ if (
609
+ selector.recordType !== undefined &&
610
+ entry.recordType !== selector.recordType
611
+ ) {
612
+ return false;
613
+ }
614
+ return true;
615
+ });
616
+ }
617
+
618
+ function describeMatches(matches: readonly EntryMatch[]): {
619
+ cdbRecordIndex: number;
620
+ target: string;
621
+ user: string;
622
+ recordType: number;
623
+ }[] {
624
+ return matches.map(({ entry, index }) => ({
625
+ cdbRecordIndex: index,
626
+ target: entry.target,
627
+ user: entry.user,
628
+ recordType: entry.recordType,
629
+ }));
630
+ }
631
+
632
+ export function showDevice(
633
+ args: ShowDeviceArgs,
634
+ ): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta> {
635
+ const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
636
+ const data: DevicesShowEnvelopeData = {
637
+ entry: entryToShowItem(match.entry, match.index),
638
+ };
639
+ if (args.explain) {
640
+ data.record = match.entry.record;
641
+ }
642
+
643
+ const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
644
+
645
+ return {
646
+ ok: true,
647
+ data,
648
+ warnings: [...args.cdb.warnings, ...resolved.warnings],
649
+ tips: credentialsMissingTips(match.entry, args.cdb.entries),
650
+ meta: devicesMeta("show", args.cdb.settings, resolved.target),
651
+ };
652
+ }
653
+
654
+ function entryToListItem(
655
+ entry: WinBoxCdbEntry,
656
+ cdbRecordIndex: number,
657
+ ): DevicesListItem {
658
+ const parsed = parseCommentKv(entry.comment);
659
+ const sources: Record<string, SettingSource> = {
660
+ target: { kind: "cdb", key: `record:${cdbRecordIndex}:target` },
661
+ user: { kind: "cdb", key: `record:${cdbRecordIndex}:user` },
662
+ group: { kind: "cdb", key: `record:${cdbRecordIndex}:group` },
663
+ };
664
+ const item: DevicesListItem = {
665
+ target: entry.target,
666
+ recordType: entry.recordType,
667
+ recordTypeName: recordTypeName(entry.recordType),
668
+ group: entry.group,
669
+ user: entry.user,
670
+ cdbRecordIndex,
671
+ sources,
672
+ };
673
+ if (parsed.values.source !== undefined) {
674
+ item.source = parsed.values.source;
675
+ sources["source"] = {
676
+ kind: "comment-kv",
677
+ key: `record:${cdbRecordIndex}:source`,
678
+ };
679
+ }
680
+ return item;
681
+ }
682
+
683
+ interface ResolvedDeviceTargetMeta {
684
+ target: EnvelopeTargetMeta;
685
+ warnings: DevicesWarning[];
686
+ }
687
+
688
+ function resolveDeviceShowTargetMeta(
689
+ entry: WinBoxCdbEntry,
690
+ cdbRecordIndex: number,
691
+ args: ShowDeviceArgs,
692
+ ): ResolvedDeviceTargetMeta {
693
+ const parsed = parseCommentKv(entry.comment);
694
+ const warnings: DevicesWarning[] = parsed.warnings.map((warning) => ({
695
+ code: warning.code,
696
+ message: warning.message,
697
+ context: warning.context,
698
+ }));
699
+ const sources: Record<string, SettingSource> = {
700
+ target: { kind: "cdb", key: `record:${cdbRecordIndex}:target` },
701
+ user: { kind: "cdb", key: `record:${cdbRecordIndex}:user` },
702
+ password: { kind: "cdb", key: `record:${cdbRecordIndex}:password` },
703
+ group: { kind: "cdb", key: `record:${cdbRecordIndex}:group` },
704
+ };
705
+
706
+ const commentVia = parseProtocolOption(
707
+ parsed.values.via,
708
+ "via",
709
+ cdbRecordIndex,
710
+ warnings,
711
+ );
712
+ const envVia = parseProtocolOption(
713
+ args.env?.["CENTRS_VIA"],
714
+ "CENTRS_VIA",
715
+ cdbRecordIndex,
716
+ warnings,
717
+ );
718
+ const cliVia = parseProtocolOption(
719
+ args.via,
720
+ "--via",
721
+ cdbRecordIndex,
722
+ warnings,
723
+ );
724
+ const via = cliVia ?? envVia ?? commentVia;
725
+ if (via !== undefined) {
726
+ sources["via"] =
727
+ cliVia !== undefined
728
+ ? { kind: "cli", key: "--via" }
729
+ : envVia !== undefined
730
+ ? { kind: "env", key: "CENTRS_VIA" }
731
+ : { kind: "comment-kv", key: `record:${cdbRecordIndex}:via` };
732
+ }
733
+ if (commentVia !== undefined && via !== commentVia) {
734
+ warnings.push({
735
+ code: "cdb/override-applied",
736
+ message: `Comment option "via=${commentVia}" was overridden by ${sources["via"]?.kind ?? "a higher-precedence source"}.`,
737
+ context: {
738
+ target: entry.target,
739
+ key: "via",
740
+ commentValue: commentVia,
741
+ winner: via,
742
+ source: sources["via"],
743
+ },
744
+ });
745
+ }
746
+ if (parsed.values.validate !== undefined) {
747
+ sources["validate"] = {
748
+ kind: "comment-kv",
749
+ key: `record:${cdbRecordIndex}:validate`,
750
+ };
751
+ }
752
+ if (parsed.values.source !== undefined) {
753
+ sources["source"] = {
754
+ kind: "comment-kv",
755
+ key: `record:${cdbRecordIndex}:source`,
756
+ };
757
+ }
758
+
759
+ const target: EnvelopeTargetMeta = {
760
+ input: args.target,
761
+ target: entry.target,
762
+ resolvedTarget: entry.target,
763
+ host: entry.target,
764
+ identity: parsed.lookups.identity ?? entry.target,
765
+ recordIndex: cdbRecordIndex,
766
+ cdbRecordIndex,
767
+ user: entry.user,
768
+ group: entry.group,
769
+ source: { kind: "cdb", key: `record:${cdbRecordIndex}` },
770
+ sources,
771
+ };
772
+ if (via !== undefined) {
773
+ target.via = via;
774
+ }
775
+ if (parsed.values.validate !== undefined) {
776
+ target.validate = parsed.values.validate !== "false";
777
+ }
778
+ if (parsed.values.source !== undefined) {
779
+ target.discoverySource = parsed.values.source;
780
+ }
781
+ return { target, warnings };
782
+ }
783
+
784
+ function parseProtocolOption(
785
+ value: string | undefined,
786
+ key: string,
787
+ recordIndex: number,
788
+ warnings: DevicesWarning[],
789
+ ): RouterOsProtocol | undefined {
790
+ if (value === undefined) {
791
+ return undefined;
792
+ }
793
+ if (plannedProtocols.includes(value as RouterOsProtocol)) {
794
+ return value as RouterOsProtocol;
795
+ }
796
+ warnings.push({
797
+ code: "cdb/invalid-option",
798
+ message: `Comment option "${key}=${value}" is invalid (unknown protocol); it is ignored.`,
799
+ context: { key, value, recordIndex },
800
+ });
801
+ return undefined;
802
+ }
803
+
804
+ function entryToShowItem(
805
+ entry: WinBoxCdbEntry,
806
+ cdbRecordIndex: number,
807
+ ): DevicesShowItem {
808
+ return {
809
+ target: entry.target,
810
+ recordType: entry.recordType,
811
+ recordTypeName: recordTypeName(entry.recordType),
812
+ group: entry.group,
813
+ user: entry.user,
814
+ password: entry.password,
815
+ session: entry.session,
816
+ comment: entry.comment,
817
+ commentMirror: entry.commentMirror,
818
+ profile: entry.profile,
819
+ romonAgent: entry.romonAgent,
820
+ savedPassword: entry.savedPassword,
821
+ flags: entry.flags,
822
+ cdbRecordIndex,
823
+ };
824
+ }
825
+
826
+ export interface ListGroupsArgs {
827
+ cdb: LoadedCdb;
828
+ withMembers?: boolean;
829
+ }
830
+
831
+ export function listGroups(
832
+ args: ListGroupsArgs,
833
+ ): CentrsSuccessEnvelope<readonly DevicesGroupSummary[], DevicesOperationMeta> {
834
+ const buckets = new Map<string, { entry: WinBoxCdbEntry; index: number }[]>();
835
+ for (let index = 0; index < args.cdb.entries.length; index += 1) {
836
+ const entry = args.cdb.entries[index];
837
+ if (!entry || entry.group === "") {
838
+ continue;
839
+ }
840
+ const bucket = buckets.get(entry.group) ?? [];
841
+ bucket.push({ entry, index });
842
+ buckets.set(entry.group, bucket);
843
+ }
844
+
845
+ const sorted = [...buckets.entries()].sort(([a], [b]) =>
846
+ a < b ? -1 : a > b ? 1 : 0,
847
+ );
848
+ const data: DevicesGroupSummary[] = sorted.map(([group, members]) => {
849
+ const summary: DevicesGroupSummary = {
850
+ group,
851
+ members: members.length,
852
+ };
853
+ if (args.withMembers) {
854
+ summary.memberEntries = members.map(({ entry }) => ({
855
+ target: entry.target,
856
+ recordType: entry.recordType,
857
+ }));
858
+ }
859
+ return summary;
860
+ });
861
+
862
+ return {
863
+ ok: true,
864
+ data,
865
+ warnings: args.cdb.warnings,
866
+ tips: [],
867
+ meta: devicesMeta("groups", args.cdb.settings),
868
+ };
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // Mutation (CDB write) surface
873
+ // ---------------------------------------------------------------------------
874
+
875
+ /**
876
+ * Result payload shared by every mutating devices command. `entry` is the
877
+ * resulting record (absent for `remove`); `preservedUnknownTags` lists any
878
+ * unknown/`rawTail` field tags carried over verbatim from the prior record.
879
+ */
880
+ export interface DevicesMutationData {
881
+ action: "add" | "edit" | "set" | "remove";
882
+ target: string;
883
+ cdbRecordIndex: number;
884
+ replaced: boolean;
885
+ recordCount: number;
886
+ backupPath?: string;
887
+ prunedBackups: readonly string[];
888
+ preservedUnknownTags?: readonly number[];
889
+ entry?: DevicesShowItem;
890
+ }
891
+
892
+ export type DevicesMutationEnvelope = CentrsSuccessEnvelope<
893
+ DevicesMutationData,
894
+ DevicesOperationMeta
895
+ >;
896
+
897
+ const KNOWN_FIELD_TAGS = new Set<number>(Object.values(winBoxCdbFieldTag));
898
+
899
+ interface EntryMatch {
900
+ entry: WinBoxCdbEntry;
901
+ index: number;
902
+ }
903
+
904
+ /**
905
+ * Entries whose (target, user) pair matches — the natural CDB record identity
906
+ * (WinBox keys "Save to list" on address + user, so the same address under a
907
+ * different user is a second record, not an update). `target` is canonicalized
908
+ * so a MAC compares case/separator-insensitively; `user` is exact (empty string
909
+ * when unset). See `commands/devices/README.md` (Identity model) and
910
+ * `docs/CONSTITUTION.md` (Identity and CDB).
911
+ */
912
+ function matchTargetUser(
913
+ entries: readonly WinBoxCdbEntry[],
914
+ target: string,
915
+ user: string,
916
+ ): EntryMatch[] {
917
+ const want = canonicalRouterKey(target);
918
+ const matches: EntryMatch[] = [];
919
+ for (let index = 0; index < entries.length; index += 1) {
920
+ const entry = entries[index];
921
+ if (
922
+ entry &&
923
+ canonicalRouterKey(entry.target) === want &&
924
+ entry.user === user
925
+ ) {
926
+ matches.push({ entry, index });
927
+ }
928
+ }
929
+ return matches;
930
+ }
931
+
932
+ /** Fields the canonical builder does not emit and must be preserved verbatim. */
933
+ function extraFields(record: WinBoxCdbRecord): readonly WinBoxCdbField[] {
934
+ return record.fields.filter(
935
+ (field) => field.rawTail === true || !KNOWN_FIELD_TAGS.has(field.tag),
936
+ );
937
+ }
938
+
939
+ /**
940
+ * Field-tag order to reuse when REWRITING an existing record. The builder's
941
+ * canonicalFieldOrder is shaped for ipAdmin records; reusing it for other record
942
+ * types (mac/romon targets) would reshape their on-disk layout. Deriving the
943
+ * order from the prior record keeps each type's layout intact. The numeric
944
+ * record-type marker (tag=recordType with a non-string value) is dropped because
945
+ * the builder re-emits it unconditionally; rawTail/unknown fields are carried
946
+ * separately via {@link extraFields}. Any tag in `newlySet` that the prior layout
947
+ * lacked is appended so freshly supplied values are still encoded.
948
+ */
949
+ function preservedFieldOrder(
950
+ record: WinBoxCdbRecord,
951
+ newlySet: readonly number[] = [],
952
+ ): readonly number[] {
953
+ const order: number[] = [];
954
+ const seen = new Set<number>();
955
+ for (const field of record.fields) {
956
+ if (field.rawTail === true || !KNOWN_FIELD_TAGS.has(field.tag)) {
957
+ continue;
958
+ }
959
+ if (
960
+ field.tag === winBoxCdbFieldTag.recordType &&
961
+ typeof field.value !== "string"
962
+ ) {
963
+ continue;
964
+ }
965
+ if (!seen.has(field.tag)) {
966
+ seen.add(field.tag);
967
+ order.push(field.tag);
968
+ }
969
+ }
970
+ for (const tag of newlySet) {
971
+ if (!seen.has(tag)) {
972
+ seen.add(tag);
973
+ order.push(tag);
974
+ }
975
+ }
976
+ return order;
977
+ }
978
+
979
+ /**
980
+ * Resolves the saved-password flag for an edit. An explicit `savedPassword`
981
+ * argument wins; otherwise a newly supplied password re-derives the flag from
982
+ * its length, and an unchanged password preserves the prior flag bit (so a
983
+ * comment-only edit never flips it).
984
+ */
985
+ function resolveSavedPassword(
986
+ explicit: boolean | undefined,
987
+ newPassword: string | undefined,
988
+ prior: boolean,
989
+ ): boolean {
990
+ if (explicit !== undefined) {
991
+ return explicit;
992
+ }
993
+ if (newPassword !== undefined) {
994
+ return newPassword.length > 0;
995
+ }
996
+ return prior;
997
+ }
998
+
999
+ function preservedTags(
1000
+ fields: readonly WinBoxCdbField[],
1001
+ ): readonly number[] | undefined {
1002
+ if (fields.length === 0) {
1003
+ return undefined;
1004
+ }
1005
+ return fields.map((field) => field.tag);
1006
+ }
1007
+
1008
+ /**
1009
+ * When the CDB was loaded from encrypted bytes, derive the `encryptWith` option
1010
+ * for {@link writeWinBoxCdb} from the password we already have in settings, so
1011
+ * the round-trip re-encrypts with the same secret. Returns `undefined` for an
1012
+ * open CDB so the writer keeps emitting open bytes.
1013
+ */
1014
+ function encryptWithFor(cdb: LoadedCdb): { password: string } | undefined {
1015
+ if (!cdb.encrypted) {
1016
+ return undefined;
1017
+ }
1018
+ return { password: cdb.settings.cdbPassword.value };
1019
+ }
1020
+
1021
+ function ambiguousTargetError(
1022
+ target: string,
1023
+ matches: readonly EntryMatch[],
1024
+ ): CentrsError {
1025
+ return new CentrsError({
1026
+ code: "identity/ambiguous",
1027
+ summary: `Target "${target}" matches ${matches.length} CDB entries; refusing to mutate an ambiguous target.`,
1028
+ remediation:
1029
+ "Remove the duplicate entry, or narrow the CDB so a single record owns this target.",
1030
+ context: {
1031
+ target,
1032
+ matches: matches.map(({ entry, index }) => ({
1033
+ cdbRecordIndex: index,
1034
+ target: entry.target,
1035
+ recordType: entry.recordType,
1036
+ })),
1037
+ },
1038
+ });
1039
+ }
1040
+
1041
+ /** Resolve a record-type name (e.g. `ipAdmin`) to its numeric tag. */
1042
+ export function recordTypeFromName(name: string): number | undefined {
1043
+ const value = (winBoxCdbRecordType as Record<string, number>)[name];
1044
+ return typeof value === "number" ? value : undefined;
1045
+ }
1046
+
1047
+ async function persistRecords(
1048
+ cdb: LoadedCdb,
1049
+ records: readonly WinBoxCdbRecord[],
1050
+ options: WriteWinBoxCdbOptions | undefined,
1051
+ action: DevicesMutationData["action"],
1052
+ target: string,
1053
+ cdbRecordIndex: number,
1054
+ replaced: boolean,
1055
+ resultEntry: DevicesShowItem | undefined,
1056
+ preserved: readonly number[] | undefined,
1057
+ warnings: readonly DevicesWarning[],
1058
+ ): Promise<DevicesMutationEnvelope> {
1059
+ const encryptWith = encryptWithFor(cdb);
1060
+ const writeOptions: WriteWinBoxCdbOptions | undefined = encryptWith
1061
+ ? { ...(options ?? {}), encryptWith }
1062
+ : options;
1063
+ const written = await writeWinBoxCdb(
1064
+ cdb.settings.cdbFile.value,
1065
+ records,
1066
+ writeOptions,
1067
+ );
1068
+ const data: DevicesMutationData = {
1069
+ action,
1070
+ target,
1071
+ cdbRecordIndex,
1072
+ replaced,
1073
+ recordCount: written.recordCount,
1074
+ prunedBackups: written.prunedBackups,
1075
+ };
1076
+ if (written.backupPath !== undefined) {
1077
+ data.backupPath = written.backupPath;
1078
+ }
1079
+ if (preserved !== undefined) {
1080
+ data.preservedUnknownTags = preserved;
1081
+ }
1082
+ if (resultEntry !== undefined) {
1083
+ data.entry = resultEntry;
1084
+ }
1085
+ return {
1086
+ ok: true,
1087
+ data,
1088
+ warnings,
1089
+ tips: [],
1090
+ meta: devicesMeta(action, cdb.settings),
1091
+ };
1092
+ }
1093
+
1094
+ const ALLOWLIST_SET = new Set<string>(commentKvAllowlist);
1095
+ const RESERVED_SET = new Set<string>(commentKvReservedKeys);
1096
+ const LOOKUP_SET = new Set<string>(commentKvLookupKeys);
1097
+
1098
+ /**
1099
+ * The `usage/not-implemented` error raised by `devices edit`, whose interactive
1100
+ * (clack/TUI) editor is reserved for the future. Field/metadata changes go
1101
+ * through `devices set`. Shared by the CLI and MCP so both surfaces agree.
1102
+ */
1103
+ export function editInteractiveOnlyError(): CentrsError {
1104
+ return new CentrsError({
1105
+ code: "usage/not-implemented",
1106
+ summary:
1107
+ "`devices edit` is the interactive editor, which is not implemented yet.",
1108
+ remediation:
1109
+ "Use `centrs devices set <target> [--user …] [--password …] [k=v …]` for non-interactive field and metadata changes.",
1110
+ });
1111
+ }
1112
+
1113
+ /**
1114
+ * Validate the `k=v` comment positionals shared by `add` and `set`. First-class
1115
+ * CDB fields are reserved and throw `cdb/reserved-key` (use the matching flag);
1116
+ * tokens outside the recognized override + lookup keys throw `cdb/unknown-option`
1117
+ * under `strict`, else append a `cdb/unknown-option` warning. Recognized keys
1118
+ * (allowlist overrides + `identity`/`mac`/`ip` lookups) pass silently.
1119
+ */
1120
+ function validateCommentKvUpdates(
1121
+ updates: readonly CommentKvUpdate[],
1122
+ target: string,
1123
+ strict: boolean | undefined,
1124
+ warnings: DevicesWarning[],
1125
+ ): void {
1126
+ const reserved = updates
1127
+ .map((update) => update.key)
1128
+ .filter((key) => RESERVED_SET.has(key));
1129
+ if (reserved.length > 0) {
1130
+ throw new CentrsError({
1131
+ code: "cdb/reserved-key",
1132
+ summary: `Comment kv-soup cannot carry first-class CDB field(s): ${reserved.join(", ")}.`,
1133
+ remediation: `Set ${reserved.join(", ")} with the matching --flag; the comment kv-soup is for ${commentKvAllowlist.join(", ")} plus identity/mac/ip.`,
1134
+ context: { target, reservedKeys: reserved },
1135
+ });
1136
+ }
1137
+ for (const key of updates.map((update) => update.key)) {
1138
+ if (ALLOWLIST_SET.has(key) || LOOKUP_SET.has(key)) {
1139
+ continue;
1140
+ }
1141
+ if (strict) {
1142
+ throw new CentrsError({
1143
+ code: "cdb/unknown-option",
1144
+ summary: `Unknown comment option "${key}" rejected under --strict.`,
1145
+ remediation: `Drop --strict to write it verbatim, or use an allowlisted key: ${commentKvAllowlist.join(", ")}.`,
1146
+ context: { target, key },
1147
+ });
1148
+ }
1149
+ warnings.push({
1150
+ code: "cdb/unknown-option",
1151
+ message: `Comment option "${key}" is not recognized; it is written verbatim but has no effect.`,
1152
+ context: { target, key },
1153
+ });
1154
+ }
1155
+ }
1156
+
1157
+ export interface AddDeviceArgs {
1158
+ cdb: LoadedCdb;
1159
+ target: string;
1160
+ recordType?: number;
1161
+ user?: string;
1162
+ password?: string;
1163
+ group?: string;
1164
+ profile?: string;
1165
+ session?: string;
1166
+ comment?: string;
1167
+ /** `k=v` comment positionals upserted on top of `comment` (symmetric with set). */
1168
+ commentKvUpdates?: readonly CommentKvUpdate[];
1169
+ savedPassword?: boolean;
1170
+ force?: boolean;
1171
+ strict?: boolean;
1172
+ writeOptions?: WriteWinBoxCdbOptions;
1173
+ }
1174
+
1175
+ export async function addDevice(
1176
+ args: AddDeviceArgs,
1177
+ ): Promise<DevicesMutationEnvelope> {
1178
+ const user = args.user ?? "";
1179
+ const matches = matchTargetUser(args.cdb.entries, args.target, user);
1180
+ if (matches.length > 1) {
1181
+ throw ambiguousTargetError(args.target, matches);
1182
+ }
1183
+ const existing = matches[0];
1184
+ if (existing && !args.force) {
1185
+ throw new CentrsError({
1186
+ code: "cdb/already-exists",
1187
+ summary: `A CDB entry for target "${args.target}" under user "${user}" already exists.`,
1188
+ remediation:
1189
+ "Pass --force to overwrite it, add it under a different --user, or use `centrs devices set` to change fields.",
1190
+ context: { target: args.target, user, cdbRecordIndex: existing.index },
1191
+ });
1192
+ }
1193
+
1194
+ const warnings: DevicesWarning[] = [...args.cdb.warnings];
1195
+ const updates = args.commentKvUpdates ?? [];
1196
+ validateCommentKvUpdates(updates, args.target, args.strict, warnings);
1197
+ const comment =
1198
+ updates.length > 0 || args.comment !== undefined
1199
+ ? applyCommentKv(args.comment ?? "", updates)
1200
+ : undefined;
1201
+
1202
+ const carried = existing ? extraFields(existing.entry.record) : [];
1203
+ // Validate the logical record against the canonical schema before rendering
1204
+ // it to CDB bytes — a blank target or wrong field type fails here for every
1205
+ // caller (CLI, MCP, direct API), not just the CLI's own guards.
1206
+ const validated = parseDeviceRecord({
1207
+ recordType: args.recordType ?? winBoxCdbRecordType.ipAdmin,
1208
+ target: args.target,
1209
+ user: args.user,
1210
+ password: args.password,
1211
+ session: args.session,
1212
+ comment,
1213
+ group: args.group,
1214
+ profile: args.profile,
1215
+ savedPassword: args.savedPassword,
1216
+ });
1217
+ const record = buildWinBoxCdbEntryRecord({
1218
+ recordType: validated.recordType,
1219
+ target: validated.target,
1220
+ user: validated.user,
1221
+ password: validated.password,
1222
+ session: validated.session,
1223
+ comment,
1224
+ group: validated.group,
1225
+ profile: validated.profile,
1226
+ savedPassword: validated.savedPassword,
1227
+ extraFields: carried.length > 0 ? carried : undefined,
1228
+ });
1229
+
1230
+ const records = args.cdb.entries.map((entry) => entry.record);
1231
+ let cdbRecordIndex: number;
1232
+ if (existing) {
1233
+ records[existing.index] = record;
1234
+ cdbRecordIndex = existing.index;
1235
+ } else {
1236
+ cdbRecordIndex = records.length;
1237
+ records.push(record);
1238
+ }
1239
+
1240
+ const preserved = preservedTags(carried);
1241
+ appendUnknownFieldWarning(warnings, args.cdb.settings, preserved);
1242
+ return persistRecords(
1243
+ args.cdb,
1244
+ records,
1245
+ args.writeOptions,
1246
+ "add",
1247
+ args.target,
1248
+ cdbRecordIndex,
1249
+ existing !== undefined,
1250
+ entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
1251
+ preserved,
1252
+ warnings,
1253
+ );
1254
+ }
1255
+
1256
+ export interface SetDeviceArgs {
1257
+ cdb: LoadedCdb;
1258
+ target: string;
1259
+ /** Disambiguator passed through to {@link requireSingleMatch}. */
1260
+ match?: string;
1261
+ /** `k=v` comment positionals (overrides + identity/mac/ip lookups). */
1262
+ updates?: readonly CommentKvUpdate[];
1263
+ user?: string;
1264
+ password?: string;
1265
+ group?: string;
1266
+ profile?: string;
1267
+ session?: string;
1268
+ savedPassword?: boolean;
1269
+ strict?: boolean;
1270
+ writeOptions?: WriteWinBoxCdbOptions;
1271
+ }
1272
+
1273
+ /**
1274
+ * Modify an existing CDB record (the `set` verb). Symmetric with {@link addDevice}:
1275
+ * first-class fields change via flags, comment override/lookup keys via `k=v`
1276
+ * positionals. Resolves `<router>` (target or lookup key) to a single record via
1277
+ * {@link requireSingleMatch}; a missing target throws `cdb/not-found-target`.
1278
+ * Unchanged fields and the comment's free-form prose are preserved verbatim.
1279
+ */
1280
+ export async function setDevice(
1281
+ args: SetDeviceArgs,
1282
+ ): Promise<DevicesMutationEnvelope> {
1283
+ const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
1284
+ const updates = args.updates ?? [];
1285
+ const warnings: DevicesWarning[] = [...args.cdb.warnings];
1286
+ validateCommentKvUpdates(updates, args.target, args.strict, warnings);
1287
+
1288
+ const prior = match.entry;
1289
+ const comment = applyCommentKv(prior.comment, updates);
1290
+ const carried = extraFields(prior.record);
1291
+
1292
+ const newlySet: number[] = [];
1293
+ if (args.user !== undefined) newlySet.push(winBoxCdbFieldTag.user);
1294
+ if (args.password !== undefined) newlySet.push(winBoxCdbFieldTag.password);
1295
+ if (args.session !== undefined) newlySet.push(winBoxCdbFieldTag.session);
1296
+ if (args.group !== undefined) newlySet.push(winBoxCdbFieldTag.group);
1297
+ if (args.profile !== undefined) newlySet.push(winBoxCdbFieldTag.profile);
1298
+ if (updates.length > 0) {
1299
+ newlySet.push(winBoxCdbFieldTag.comment, winBoxCdbFieldTag.commentMirror);
1300
+ }
1301
+
1302
+ const savedPassword = resolveSavedPassword(
1303
+ args.savedPassword,
1304
+ args.password,
1305
+ prior.savedPassword,
1306
+ );
1307
+ // Validate the merged record (prior fields + this edit) against the canonical
1308
+ // schema. recordType/target are inherited and the schema is lenient about
1309
+ // record types it does not name, so an edit never regresses on an unusual
1310
+ // existing record; it does catch a wrong field type from a non-CLI caller.
1311
+ parseDeviceRecord({
1312
+ recordType: prior.recordType,
1313
+ target: prior.target,
1314
+ user: args.user ?? prior.user,
1315
+ password: args.password ?? prior.password,
1316
+ session: args.session ?? prior.session,
1317
+ comment,
1318
+ group: args.group ?? prior.group,
1319
+ profile: args.profile ?? (prior.profile || undefined),
1320
+ savedPassword,
1321
+ });
1322
+ const record = buildWinBoxCdbEntryRecord({
1323
+ recordType: prior.recordType,
1324
+ target: prior.target,
1325
+ user: args.user ?? prior.user,
1326
+ password: args.password ?? prior.password,
1327
+ session: args.session ?? prior.session,
1328
+ comment,
1329
+ group: args.group ?? prior.group,
1330
+ profile: args.profile ?? (prior.profile || undefined),
1331
+ romonAgent: prior.romonAgent || undefined,
1332
+ savedPassword,
1333
+ fieldOrder: preservedFieldOrder(prior.record, newlySet),
1334
+ declaredFieldCount: prior.record.declaredFieldCount,
1335
+ extraFields: carried.length > 0 ? carried : undefined,
1336
+ });
1337
+
1338
+ const records = args.cdb.entries.map((entry) => entry.record);
1339
+ records[match.index] = record;
1340
+
1341
+ const preserved = preservedTags(carried);
1342
+ appendUnknownFieldWarning(warnings, args.cdb.settings, preserved);
1343
+ return persistRecords(
1344
+ args.cdb,
1345
+ records,
1346
+ args.writeOptions,
1347
+ "set",
1348
+ args.target,
1349
+ match.index,
1350
+ true,
1351
+ entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
1352
+ preserved,
1353
+ warnings,
1354
+ );
1355
+ }
1356
+
1357
+ export interface RemoveDeviceArgs {
1358
+ cdb: LoadedCdb;
1359
+ target: string;
1360
+ /** Disambiguator passed through to {@link requireSingleMatch}. */
1361
+ match?: string;
1362
+ writeOptions?: WriteWinBoxCdbOptions;
1363
+ }
1364
+
1365
+ export async function removeDevice(
1366
+ args: RemoveDeviceArgs,
1367
+ ): Promise<DevicesMutationEnvelope> {
1368
+ const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
1369
+ const records = args.cdb.entries
1370
+ .map((entry) => entry.record)
1371
+ .filter((_, index) => index !== match.index);
1372
+
1373
+ return persistRecords(
1374
+ args.cdb,
1375
+ records,
1376
+ args.writeOptions,
1377
+ "remove",
1378
+ args.target,
1379
+ match.index,
1380
+ false,
1381
+ undefined,
1382
+ undefined,
1383
+ [...args.cdb.warnings],
1384
+ );
1385
+ }
1386
+
1387
+ /**
1388
+ * Resolve `<router>` to exactly one CDB entry — the shared selection used by
1389
+ * `show` and the mutating verbs (`set`/`remove`). Matches by `target` or an
1390
+ * `identity=`/`mac=`/`ip=` lookup key, then narrows duplicates with the optional
1391
+ * `--match` selector (`user=`/`target=`/record-type). Throws
1392
+ * `cdb/not-found-target`, `identity/no-match`, or `identity/ambiguous`.
1393
+ */
1394
+ function requireSingleMatch(
1395
+ entries: readonly WinBoxCdbEntry[],
1396
+ router: string,
1397
+ match?: string,
1398
+ ): EntryMatch {
1399
+ const matches = matchRouter(entries, router);
1400
+ if (matches.length === 0) {
1401
+ throw new CentrsError({
1402
+ code: "cdb/not-found-target",
1403
+ summary: `No CDB entry matches "${router}".`,
1404
+ remediation:
1405
+ "Run `centrs devices list` to see the available targets; `<router>` resolves by target, identity=, mac=, or ip=. Check spelling and address family.",
1406
+ context: { target: router },
1407
+ });
1408
+ }
1409
+
1410
+ let selected = matches;
1411
+ if (match !== undefined) {
1412
+ const selector = parseMatchSelector(match, router);
1413
+ selected = applyMatchSelector(matches, selector);
1414
+ if (selected.length === 0) {
1415
+ throw new CentrsError({
1416
+ code: "identity/no-match",
1417
+ summary: `"${router}" has no CDB entry matching --match "${match}".`,
1418
+ remediation:
1419
+ "Run `centrs devices list` to see each candidate's user and record type, then pass a --match that exists.",
1420
+ context: { target: router, match, matches: describeMatches(matches) },
1421
+ });
1422
+ }
1423
+ }
1424
+
1425
+ if (selected.length > 1) {
1426
+ const recordTypeTokens = Object.keys(winBoxCdbRecordType).join(", ");
1427
+ throw new CentrsError({
1428
+ code: "identity/ambiguous",
1429
+ summary: `"${router}" matches ${selected.length} CDB entries.`,
1430
+ remediation: `Re-run with --match user=<name>, --match target=<addr>, or --match <record-type> (one of ${recordTypeTokens}) to select among the duplicates, or remove the duplicate CDB entry.`,
1431
+ context: { target: router, matches: describeMatches(selected) },
1432
+ });
1433
+ }
1434
+
1435
+ const match0 = selected[0];
1436
+ if (!match0) {
1437
+ throw new CentrsError({
1438
+ code: "internal/unreachable",
1439
+ summary: "Match list collapsed unexpectedly.",
1440
+ });
1441
+ }
1442
+ return match0;
1443
+ }
1444
+
1445
+ function appendUnknownFieldWarning(
1446
+ warnings: DevicesWarning[],
1447
+ settings: DevicesSettings,
1448
+ preserved: readonly number[] | undefined,
1449
+ ): void {
1450
+ if (preserved && preserved.length > 0) {
1451
+ warnings.push({
1452
+ code: "cdb/unknown-field",
1453
+ message: `Preserved ${preserved.length} unknown CDB field(s) verbatim on the rewritten record.`,
1454
+ context: { cdbFile: settings.cdbFile.value, tags: preserved },
1455
+ });
1456
+ }
1457
+ }
1458
+
1459
+ export function buildDevicesErrorEnvelope(
1460
+ command: DevicesCommand,
1461
+ settings: DevicesSettings,
1462
+ warnings: readonly DevicesWarning[],
1463
+ error: unknown,
1464
+ ): CentrsEnvelope<never, DevicesOperationMeta> {
1465
+ const centrsError =
1466
+ error instanceof CentrsError
1467
+ ? error
1468
+ : new CentrsError({
1469
+ code: "internal/devices-failed",
1470
+ summary: error instanceof Error ? error.message : String(error),
1471
+ cause: error,
1472
+ });
1473
+ return {
1474
+ ok: false,
1475
+ error: serializeCentrsError(centrsError),
1476
+ warnings,
1477
+ tips: [],
1478
+ meta: devicesMeta(command, settings),
1479
+ };
1480
+ }
1481
+
1482
+ export type DevicesOutputFormat = "text" | "json" | "yaml";
1483
+ export const devicesOutputFormats: readonly DevicesOutputFormat[] = [
1484
+ "text",
1485
+ "json",
1486
+ "yaml",
1487
+ ];
1488
+
1489
+ export function renderDevicesEnvelope(
1490
+ envelope: DevicesEnvelope<unknown>,
1491
+ format: DevicesOutputFormat,
1492
+ ): string {
1493
+ if (format === "json") {
1494
+ return JSON.stringify(envelope, jsonReplacer, 2);
1495
+ }
1496
+ if (format === "yaml") {
1497
+ return renderYaml(envelope);
1498
+ }
1499
+ return renderText(envelope);
1500
+ }
1501
+
1502
+ function jsonReplacer(_key: string, value: unknown): unknown {
1503
+ if (value instanceof Uint8Array) {
1504
+ return { $bytes: Array.from(value) };
1505
+ }
1506
+ return value;
1507
+ }
1508
+
1509
+ function renderYaml(value: unknown, indent = ""): string {
1510
+ if (value === null || value === undefined) {
1511
+ return `${indent}null`;
1512
+ }
1513
+ if (typeof value === "string") {
1514
+ return JSON.stringify(value);
1515
+ }
1516
+ if (typeof value === "number" || typeof value === "boolean") {
1517
+ return String(value);
1518
+ }
1519
+ if (value instanceof Uint8Array) {
1520
+ return `[${Array.from(value).join(", ")}]`;
1521
+ }
1522
+ if (Array.isArray(value)) {
1523
+ if (value.length === 0) {
1524
+ return "[]";
1525
+ }
1526
+ return value
1527
+ .map((item) => `${indent}- ${renderYamlInline(item, `${indent} `)}`)
1528
+ .join("\n");
1529
+ }
1530
+ if (typeof value === "object") {
1531
+ const entries = Object.entries(value as Record<string, unknown>);
1532
+ if (entries.length === 0) {
1533
+ return "{}";
1534
+ }
1535
+ return entries
1536
+ .map(([k, v]) => {
1537
+ const inline = renderYamlInline(v, `${indent} `);
1538
+ if (inline.includes("\n")) {
1539
+ return `${indent}${k}:\n${inline}`;
1540
+ }
1541
+ return `${indent}${k}: ${inline}`;
1542
+ })
1543
+ .join("\n");
1544
+ }
1545
+ return String(value);
1546
+ }
1547
+
1548
+ function renderYamlInline(value: unknown, indent: string): string {
1549
+ if (Array.isArray(value) || (value !== null && typeof value === "object")) {
1550
+ return `\n${renderYaml(value, indent)}`;
1551
+ }
1552
+ return renderYaml(value, indent);
1553
+ }
1554
+
1555
+ function renderText(envelope: DevicesEnvelope<unknown>): string {
1556
+ const lines: string[] = [];
1557
+ if (!envelope.ok) {
1558
+ const error = envelope.error;
1559
+ lines.push(`[${error.code}] ${error.summary}`);
1560
+ if (error.remediation) {
1561
+ lines.push(`Fix: ${error.remediation}`);
1562
+ }
1563
+ if (error.detailsUrl) {
1564
+ lines.push(`Details: ${error.detailsUrl}`);
1565
+ }
1566
+ appendWarnings(lines, envelope.warnings);
1567
+ appendTips(lines, envelope.tips);
1568
+ return lines.join("\n");
1569
+ }
1570
+
1571
+ switch (envelope.meta.operation?.command) {
1572
+ case "list":
1573
+ renderListText(lines, envelope.data as readonly DevicesListItem[]);
1574
+ break;
1575
+ case "show":
1576
+ renderShowText(lines, envelope.data as DevicesShowEnvelopeData);
1577
+ break;
1578
+ case "groups":
1579
+ renderGroupsText(lines, envelope.data as readonly DevicesGroupSummary[]);
1580
+ break;
1581
+ case "add":
1582
+ case "edit":
1583
+ case "set":
1584
+ case "remove":
1585
+ renderMutationText(lines, envelope.data as DevicesMutationData);
1586
+ break;
1587
+ }
1588
+
1589
+ appendWarnings(lines, envelope.warnings);
1590
+ appendTips(lines, envelope.tips);
1591
+ return lines.join("\n");
1592
+ }
1593
+
1594
+ function appendWarnings(
1595
+ lines: string[],
1596
+ warnings: readonly DevicesWarning[],
1597
+ ): void {
1598
+ for (const warning of warnings) {
1599
+ lines.push(`warning: [${warning.code}] ${warning.message}`);
1600
+ }
1601
+ }
1602
+
1603
+ function appendTips(lines: string[], tips: readonly Tip[]): void {
1604
+ if (tips.length === 0) {
1605
+ return;
1606
+ }
1607
+ lines.push("Tips:");
1608
+ for (const item of tips) {
1609
+ lines.push(` - [${item.code}] ${item.message}`);
1610
+ if (item.fix) {
1611
+ lines.push(` fix: ${item.fix}`);
1612
+ }
1613
+ }
1614
+ }
1615
+
1616
+ function renderMutationText(lines: string[], data: DevicesMutationData): void {
1617
+ const verb =
1618
+ data.action === "remove" ? "removed" : data.replaced ? "updated" : "added";
1619
+ lines.push(`${verb} ${data.target} (cdb-index ${data.cdbRecordIndex})`);
1620
+ lines.push(`records: ${data.recordCount}`);
1621
+ if (data.backupPath) {
1622
+ lines.push(`backup: ${data.backupPath}`);
1623
+ }
1624
+ if (data.prunedBackups.length > 0) {
1625
+ lines.push(`pruned-backups: ${data.prunedBackups.length}`);
1626
+ }
1627
+ if (data.preservedUnknownTags && data.preservedUnknownTags.length > 0) {
1628
+ lines.push(
1629
+ `preserved-unknown-tags: ${data.preservedUnknownTags.join(", ")}`,
1630
+ );
1631
+ }
1632
+ if (data.entry) {
1633
+ lines.push(`user: ${data.entry.user || "-"}`);
1634
+ lines.push(`group: ${data.entry.group || "-"}`);
1635
+ lines.push(`comment: ${data.entry.comment || "-"}`);
1636
+ }
1637
+ }
1638
+
1639
+ function renderListText(
1640
+ lines: string[],
1641
+ items: readonly DevicesListItem[],
1642
+ ): void {
1643
+ if (items.length === 0) {
1644
+ lines.push("(no entries)");
1645
+ return;
1646
+ }
1647
+ lines.push(["INDEX", "TARGET", "TYPE", "USER", "GROUP"].join("\t"));
1648
+ for (const item of items) {
1649
+ lines.push(
1650
+ [
1651
+ String(item.cdbRecordIndex),
1652
+ item.target,
1653
+ item.recordTypeName,
1654
+ item.user || "-",
1655
+ item.group || "-",
1656
+ ].join("\t"),
1657
+ );
1658
+ }
1659
+ }
1660
+
1661
+ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
1662
+ const entry = data.entry;
1663
+ lines.push(`target: ${entry.target}`);
1664
+ lines.push(`record-type: ${entry.recordTypeName} (${entry.recordType})`);
1665
+ lines.push(`cdb-index: ${entry.cdbRecordIndex}`);
1666
+ lines.push(`user: ${entry.user || "-"}`);
1667
+ lines.push(`saved-password: ${entry.savedPassword ? "yes" : "no"}`);
1668
+ lines.push(`group: ${entry.group || "-"}`);
1669
+ lines.push(`profile: ${entry.profile || "-"}`);
1670
+ lines.push(`session: ${entry.session || "-"}`);
1671
+ lines.push(`comment: ${entry.comment || "-"}`);
1672
+ if (entry.romonAgent) {
1673
+ lines.push(`romon-agent: ${entry.romonAgent}`);
1674
+ }
1675
+ if (data.record) {
1676
+ lines.push("");
1677
+ lines.push("record:");
1678
+ for (const field of data.record.fields) {
1679
+ lines.push(` - ${describeField(field)}`);
1680
+ }
1681
+ }
1682
+ }
1683
+
1684
+ function describeField(field: WinBoxCdbField): string {
1685
+ const head = `tag=${field.tag} marker=0x${field.marker.toString(16).padStart(2, "0")} tcode=0x${field.tcode.toString(16).padStart(2, "0")}`;
1686
+ if (field.rawTail) {
1687
+ const bytes = field.value instanceof Uint8Array ? field.value : null;
1688
+ const preview = bytes
1689
+ ? Array.from(bytes.subarray(0, 16))
1690
+ .map((b) => b.toString(16).padStart(2, "0"))
1691
+ .join(" ")
1692
+ : "<non-bytes>";
1693
+ const len = bytes ? bytes.length : 0;
1694
+ return `${head} rawTail bytes=${len} preview=${preview}`;
1695
+ }
1696
+ return `${head} value=${JSON.stringify(field.value)}`;
1697
+ }
1698
+
1699
+ function renderGroupsText(
1700
+ lines: string[],
1701
+ items: readonly DevicesGroupSummary[],
1702
+ ): void {
1703
+ if (items.length === 0) {
1704
+ lines.push("(no groups)");
1705
+ return;
1706
+ }
1707
+ for (const item of items) {
1708
+ lines.push(`${item.group}\t${item.members}`);
1709
+ if (item.memberEntries) {
1710
+ for (const member of item.memberEntries) {
1711
+ lines.push(
1712
+ ` - ${member.target} (${recordTypeName(member.recordType)})`,
1713
+ );
1714
+ }
1715
+ }
1716
+ }
1717
+ }