@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
@@ -0,0 +1,1215 @@
1
+ import type {
2
+ CentrsEnvelope,
3
+ CentrsErrorEnvelope,
4
+ CentrsSuccessEnvelope,
5
+ CommonSettingsMeta,
6
+ SettingSource as CoreSettingSource,
7
+ EnvelopeValidationMeta,
8
+ } from "./core/envelope.ts";
9
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
10
+ import {
11
+ createProtocolAdapter,
12
+ type ProtocolAdapter,
13
+ } from "./protocols/adapter.ts";
14
+ import {
15
+ getProtocolPlan,
16
+ plannedProtocols,
17
+ type RouterOsProtocol,
18
+ } from "./protocols/index.ts";
19
+ import {
20
+ type CdbResolution,
21
+ isIpTransport,
22
+ parseDuration,
23
+ parseResolvePolicy,
24
+ type ResolvedAuth,
25
+ type ResolvedSetting,
26
+ type ResolvedTarget,
27
+ resolveAuth,
28
+ resolveBooleanSetting,
29
+ resolveCdb,
30
+ resolveMacTarget,
31
+ resolveOptionalIntegerSetting,
32
+ resolveStringSetting,
33
+ resolveTarget,
34
+ toCoreSource,
35
+ } from "./resolver/index.ts";
36
+
37
+ export const retrieveOutputFormats = ["text", "json", "yaml"] as const;
38
+ export type RetrieveOutputFormat = (typeof retrieveOutputFormats)[number];
39
+
40
+ export interface RetrieveRequest {
41
+ targetInput?: string;
42
+ path: string;
43
+ via?: string;
44
+ host?: string;
45
+ port?: number;
46
+ username?: string;
47
+ password?: string;
48
+ timeout?: string | number;
49
+ format?: string;
50
+ validate?: boolean;
51
+ verbose?: boolean;
52
+ attribute?: string | readonly string[];
53
+ attributes?: string | readonly string[];
54
+ allAttributes?: boolean;
55
+ listAttributes?: boolean;
56
+ filter?: string;
57
+ query?: string;
58
+ maxResultsBytes?: number;
59
+ cdbFile?: string;
60
+ cdbPassword?: string;
61
+ /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
62
+ resolve?: string;
63
+ /** CDB group selector — expands to a fanout over all matching records. */
64
+ group?: string;
65
+ /** Bounded worker-pool size for group fanout (defaults are transport-aware). */
66
+ concurrency?: number;
67
+ }
68
+
69
+ export interface RetrieveWarning {
70
+ code: string;
71
+ message: string;
72
+ context?: Record<string, unknown>;
73
+ }
74
+
75
+ export interface RetrieveRequestSummary {
76
+ path: string;
77
+ attributes: readonly string[];
78
+ allAttributes: boolean;
79
+ listAttributes: boolean;
80
+ validate: boolean;
81
+ verbose: boolean;
82
+ timeoutMs: number;
83
+ format: RetrieveOutputFormat;
84
+ maxResultsBytes?: number;
85
+ }
86
+
87
+ export interface RetrieveOperationMeta {
88
+ kind: "attributes" | "data";
89
+ objectCount: number;
90
+ request: RetrieveRequestSummary;
91
+ auth: {
92
+ username?: string;
93
+ passwordProvided: boolean;
94
+ };
95
+ }
96
+
97
+ export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
98
+ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
99
+ unknown,
100
+ RetrieveOperationMeta
101
+ >;
102
+ export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
103
+
104
+ interface InspectChildItem {
105
+ type?: string;
106
+ name?: string;
107
+ "node-type"?: string;
108
+ }
109
+
110
+ interface InspectCompletionItem {
111
+ type?: string;
112
+ name?: string;
113
+ completion?: string;
114
+ value?: string;
115
+ text?: string;
116
+ }
117
+
118
+ interface RetrieveInspection {
119
+ command: "get" | "print";
120
+ }
121
+
122
+ export interface ResolvedRetrieveRequest {
123
+ path: string;
124
+ via: ResolvedSetting<RouterOsProtocol>;
125
+ target: ResolvedTarget;
126
+ auth: ResolvedAuth;
127
+ timeoutMs: ResolvedSetting<number>;
128
+ format: ResolvedSetting<RetrieveOutputFormat>;
129
+ validate: ResolvedSetting<boolean>;
130
+ maxResultsBytes?: ResolvedSetting<number>;
131
+ attributes: readonly string[];
132
+ allAttributes: boolean;
133
+ listAttributes: boolean;
134
+ verbose: boolean;
135
+ warnings: readonly RetrieveWarning[];
136
+ }
137
+
138
+ export async function retrieve(
139
+ request: RetrieveRequest,
140
+ env: Record<string, string | undefined> = Bun.env,
141
+ ): Promise<RetrieveSuccessEnvelope> {
142
+ const resolved = await resolveRetrieveRequest(request, env);
143
+ return runResolvedRetrieve(resolved);
144
+ }
145
+
146
+ /**
147
+ * The inspect → fetch → envelope tail shared by single-target `retrieve()` and
148
+ * group fanout. Takes an already-resolved request (one CDB record's identity +
149
+ * overrides) so fanout does not re-resolve or re-load the CDB per target. On
150
+ * success returns a `RetrieveSuccessEnvelope`; failures throw a `CentrsError`
151
+ * the caller maps with {@link buildRetrieveErrorEnvelopeFromResolved}.
152
+ */
153
+ export async function runResolvedRetrieve(
154
+ resolved: ResolvedRetrieveRequest,
155
+ ): Promise<RetrieveSuccessEnvelope> {
156
+ const warnings: RetrieveWarning[] = [...resolved.warnings];
157
+ let availableAttributes: string[] | undefined;
158
+ let inspection: RetrieveInspection | undefined;
159
+ const backend = createProtocolAdapter({
160
+ protocol: resolved.via.value,
161
+ host: resolved.target.host,
162
+ port: resolved.target.port,
163
+ tls: resolved.target.tls,
164
+ baseUrl: resolved.target.baseUrl,
165
+ username: resolved.auth.username,
166
+ password: resolved.auth.password,
167
+ timeoutMs: resolved.timeoutMs.value,
168
+ });
169
+
170
+ try {
171
+ if (resolved.listAttributes || resolved.validate.value) {
172
+ inspection = await inspectRetrievePath(resolved, backend);
173
+ }
174
+
175
+ if (
176
+ resolved.listAttributes ||
177
+ (resolved.validate.value && resolved.attributes.length > 0)
178
+ ) {
179
+ availableAttributes = await inspectAttributes(
180
+ resolved,
181
+ inspection ?? (await inspectRetrievePath(resolved, backend)),
182
+ backend,
183
+ );
184
+ }
185
+
186
+ if (resolved.listAttributes) {
187
+ const envelope = buildSuccessEnvelope(
188
+ resolved,
189
+ {
190
+ kind: "attributes",
191
+ data: availableAttributes ?? [],
192
+ },
193
+ {
194
+ enabled: true,
195
+ source: "live /console/inspect request=child+completion",
196
+ availableAttributes: availableAttributes ?? [],
197
+ },
198
+ warnings,
199
+ );
200
+ return applyMaxResultsBudget(envelope);
201
+ }
202
+
203
+ if (
204
+ resolved.validate.value &&
205
+ availableAttributes &&
206
+ resolved.attributes.length > 0
207
+ ) {
208
+ const missing = resolved.attributes.filter(
209
+ (attribute) => !availableAttributes?.includes(attribute),
210
+ );
211
+ if (missing.length > 0) {
212
+ throw new CentrsError({
213
+ code: "validation/unknown-attribute",
214
+ summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
215
+ remediation:
216
+ "Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
217
+ context: {
218
+ path: resolved.path,
219
+ requested: resolved.attributes,
220
+ availableAttributes,
221
+ },
222
+ });
223
+ }
224
+ }
225
+
226
+ const data = await executeRetrieve(resolved, backend);
227
+ const envelope = buildSuccessEnvelope(
228
+ resolved,
229
+ {
230
+ kind: "data",
231
+ data,
232
+ },
233
+ {
234
+ enabled: resolved.validate.value,
235
+ source: resolved.validate.value
236
+ ? availableAttributes
237
+ ? "live /console/inspect request=child+completion"
238
+ : "live /console/inspect request=child"
239
+ : "disabled",
240
+ availableAttributes,
241
+ },
242
+ warnings,
243
+ );
244
+ return applyMaxResultsBudget(envelope);
245
+ } finally {
246
+ await backend.close();
247
+ }
248
+ }
249
+
250
+ export function buildRetrieveErrorEnvelope(
251
+ request: RetrieveRequest,
252
+ error: unknown,
253
+ ): RetrieveErrorEnvelope {
254
+ const centrsError =
255
+ error instanceof CentrsError
256
+ ? error
257
+ : new CentrsError({
258
+ code: "internal/unhandled",
259
+ summary: "retrieve failed with an unexpected internal error.",
260
+ remediation:
261
+ "Re-run with `--format json` to capture the structured error details for debugging.",
262
+ cause: error,
263
+ });
264
+
265
+ const requestedVia = plannedProtocols.includes(
266
+ request.via as RouterOsProtocol,
267
+ )
268
+ ? (request.via as RouterOsProtocol)
269
+ : null;
270
+
271
+ return {
272
+ ok: false,
273
+ error: serializeCentrsError(centrsError),
274
+ warnings: [],
275
+ tips: [],
276
+ meta: {
277
+ target: { input: request.targetInput },
278
+ via: requestedVia,
279
+ settings: {},
280
+ },
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Error envelope for a single fanout target. Unlike
286
+ * {@link buildRetrieveErrorEnvelope} (which only knows the raw request and so
287
+ * loses `recordIndex` / `baseUrl` / `name` / per-field sources), this preserves
288
+ * the resolved per-target identity, auth, settings, and operation meta so a
289
+ * failed inner envelope carries the same provenance as a successful one.
290
+ */
291
+ export function buildRetrieveErrorEnvelopeFromResolved(
292
+ resolved: ResolvedRetrieveRequest,
293
+ error: unknown,
294
+ ): RetrieveErrorEnvelope {
295
+ const centrsError =
296
+ error instanceof CentrsError
297
+ ? error
298
+ : new CentrsError({
299
+ code: "internal/unhandled",
300
+ summary: "retrieve failed with an unexpected internal error.",
301
+ remediation:
302
+ "Re-run with `--format json` to capture the structured error details for debugging.",
303
+ cause: error,
304
+ });
305
+
306
+ const meta = metaFromResolved(resolved, {
307
+ enabled: resolved.validate.value,
308
+ source: resolved.validate.value ? "live /console/inspect" : "disabled",
309
+ result: "failed",
310
+ });
311
+
312
+ return {
313
+ ok: false,
314
+ error: serializeCentrsError(centrsError),
315
+ warnings: [...resolved.warnings],
316
+ tips: [],
317
+ meta,
318
+ };
319
+ }
320
+
321
+ export function renderRetrieveEnvelope(
322
+ envelope: RetrieveEnvelope,
323
+ format: RetrieveOutputFormat,
324
+ options: { verbose?: boolean } = {},
325
+ ): string {
326
+ switch (format) {
327
+ case "json":
328
+ return JSON.stringify(envelope, null, 2);
329
+ case "yaml":
330
+ return toYaml(envelope);
331
+ case "text":
332
+ return envelope.ok
333
+ ? renderRetrieveSuccessText(envelope, options)
334
+ : renderRetrieveErrorText(envelope, options);
335
+ default:
336
+ return exhaustiveOutputFormat(format);
337
+ }
338
+ }
339
+
340
+ function renderRetrieveSuccessText(
341
+ envelope: RetrieveSuccessEnvelope,
342
+ options: { verbose?: boolean },
343
+ ): string {
344
+ const lines: string[] = [];
345
+ const verbose = options.verbose ?? false;
346
+ const meta = envelope.meta;
347
+ const operation = meta.operation;
348
+
349
+ if (verbose) {
350
+ const target = meta.target;
351
+ lines.push(`target: ${target.input ?? target.host} -> ${target.baseUrl}`);
352
+ lines.push(`via: ${meta.via}`);
353
+ lines.push(
354
+ `sources: via=${formatCoreSource(meta.settings.via)}, host=${formatCoreSource(
355
+ meta.settings.host,
356
+ )}, port=${formatCoreSource(meta.settings.port)}, timeout=${formatCoreSource(
357
+ meta.settings.timeoutMs,
358
+ )}, format=${formatCoreSource(meta.settings.format)}, validate=${formatCoreSource(
359
+ meta.settings.validate,
360
+ )}`,
361
+ );
362
+ if (meta.validation?.enabled) {
363
+ lines.push(`validation: ${meta.validation.source}`);
364
+ }
365
+ lines.push("");
366
+ }
367
+
368
+ if (operation?.kind === "attributes") {
369
+ const attributes = Array.isArray(envelope.data)
370
+ ? (envelope.data as readonly string[])
371
+ : [];
372
+ lines.push(...attributes);
373
+ } else if (typeof envelope.data === "string") {
374
+ lines.push(envelope.data);
375
+ } else {
376
+ lines.push(JSON.stringify(envelope.data, null, 2));
377
+ }
378
+
379
+ if (envelope.warnings.length > 0) {
380
+ lines.push("");
381
+ for (const warning of envelope.warnings) {
382
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
383
+ }
384
+ }
385
+
386
+ if (envelope.tips.length > 0) {
387
+ lines.push("");
388
+ lines.push("Tips:");
389
+ for (const item of envelope.tips) {
390
+ lines.push(` - [${item.code}] ${item.message}`);
391
+ if (item.fix) {
392
+ lines.push(` fix: ${item.fix}`);
393
+ }
394
+ }
395
+ }
396
+
397
+ return lines.join("\n");
398
+ }
399
+
400
+ function renderRetrieveErrorText(
401
+ envelope: RetrieveErrorEnvelope,
402
+ options: { verbose?: boolean },
403
+ ): string {
404
+ const { error } = envelope;
405
+ const lines = [`[${error.code}] ${error.summary}`];
406
+
407
+ if (error.remediation) {
408
+ lines.push(`Fix: ${error.remediation}`);
409
+ }
410
+
411
+ if (options.verbose && error.context) {
412
+ lines.push("");
413
+ lines.push(JSON.stringify(error.context, null, 2));
414
+ }
415
+
416
+ return lines.join("\n");
417
+ }
418
+
419
+ function retrieveRequestSummary(
420
+ resolved: ResolvedRetrieveRequest,
421
+ ): RetrieveRequestSummary {
422
+ return {
423
+ path: resolved.path,
424
+ attributes: resolved.attributes,
425
+ allAttributes: resolved.allAttributes,
426
+ listAttributes: resolved.listAttributes,
427
+ validate: resolved.validate.value,
428
+ verbose: resolved.verbose,
429
+ timeoutMs: resolved.timeoutMs.value,
430
+ format: resolved.format.value,
431
+ maxResultsBytes: resolved.maxResultsBytes?.value,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Common `target` / `via` / `settings` meta for a resolved target. Shared by
437
+ * success and per-target error envelopes so both carry identical provenance.
438
+ */
439
+ function metaFromResolved(
440
+ resolved: ResolvedRetrieveRequest,
441
+ validation: EnvelopeValidationMeta,
442
+ operation?: RetrieveOperationMeta,
443
+ ): RetrieveEnvelope["meta"] {
444
+ const target = resolved.target;
445
+ const targetSources: Record<string, CoreSettingSource> = {};
446
+ for (const [field, source] of Object.entries(target.sources)) {
447
+ targetSources[field] = toCoreSource(source);
448
+ }
449
+
450
+ return {
451
+ target: {
452
+ input: target.input,
453
+ host: target.host,
454
+ port: target.port,
455
+ baseUrl: target.baseUrl,
456
+ identity: target.identity,
457
+ recordIndex: target.recordIndex,
458
+ source: toCoreSource(target.source),
459
+ sources: targetSources,
460
+ },
461
+ via: resolved.via.value,
462
+ settings: {
463
+ via: toCoreSource(resolved.via.source),
464
+ host: toCoreSource(target.hostSource),
465
+ port: toCoreSource(target.portSource),
466
+ timeoutMs: toCoreSource(resolved.timeoutMs.source),
467
+ format: toCoreSource(resolved.format.source),
468
+ validate: toCoreSource(resolved.validate.source),
469
+ maxResultsBytes: resolved.maxResultsBytes
470
+ ? toCoreSource(resolved.maxResultsBytes.source)
471
+ : undefined,
472
+ username: resolved.auth.usernameSource
473
+ ? toCoreSource(resolved.auth.usernameSource)
474
+ : undefined,
475
+ password: resolved.auth.passwordSource
476
+ ? toCoreSource(resolved.auth.passwordSource)
477
+ : undefined,
478
+ },
479
+ validation,
480
+ operation:
481
+ operation ??
482
+ ({
483
+ kind: resolved.listAttributes ? "attributes" : "data",
484
+ objectCount: 0,
485
+ request: retrieveRequestSummary(resolved),
486
+ auth: {
487
+ username: resolved.auth.username,
488
+ passwordProvided: resolved.auth.passwordProvided,
489
+ },
490
+ } satisfies RetrieveOperationMeta),
491
+ };
492
+ }
493
+
494
+ function buildSuccessEnvelope(
495
+ resolved: ResolvedRetrieveRequest,
496
+ result: { kind: "attributes" | "data"; data: unknown },
497
+ validation: EnvelopeValidationMeta,
498
+ warnings: readonly RetrieveWarning[],
499
+ ): RetrieveSuccessEnvelope {
500
+ const operation: RetrieveOperationMeta = {
501
+ kind: result.kind,
502
+ objectCount: countResultObjects(result.data),
503
+ request: retrieveRequestSummary(resolved),
504
+ auth: {
505
+ username: resolved.auth.username,
506
+ passwordProvided: resolved.auth.passwordProvided,
507
+ },
508
+ };
509
+
510
+ return {
511
+ ok: true,
512
+ data: result.data,
513
+ warnings,
514
+ tips: [],
515
+ meta: metaFromResolved(resolved, validation, operation),
516
+ };
517
+ }
518
+
519
+ function applyMaxResultsBudget(
520
+ envelope: RetrieveSuccessEnvelope,
521
+ ): RetrieveSuccessEnvelope {
522
+ const operation = envelope.meta.operation;
523
+ if (!operation) {
524
+ return envelope;
525
+ }
526
+ const requestSummary = operation.request;
527
+ const rendered = renderRetrieveEnvelope(envelope, requestSummary.format, {
528
+ verbose: requestSummary.format === "text" && requestSummary.verbose,
529
+ });
530
+ const serializedBytes = byteLength(rendered);
531
+
532
+ if (
533
+ requestSummary.maxResultsBytes !== undefined &&
534
+ serializedBytes > requestSummary.maxResultsBytes
535
+ ) {
536
+ throw new CentrsError({
537
+ code: "input/max-results-exceeded",
538
+ summary: `retrieve output exceeded the requested ${requestSummary.maxResultsBytes}-byte budget.`,
539
+ remediation:
540
+ "Increase `--max-results`, reduce the selected attributes, or switch to a more selective path.",
541
+ context: {
542
+ requiredBytes: serializedBytes,
543
+ maxResultsBytes: requestSummary.maxResultsBytes,
544
+ objectCount: operation.objectCount,
545
+ path: requestSummary.path,
546
+ },
547
+ });
548
+ }
549
+
550
+ return envelope;
551
+ }
552
+
553
+ /**
554
+ * Validate the request-shape concerns that do not depend on a target (path
555
+ * form, unimplemented flags, mutually-exclusive output flags). Shared by
556
+ * single-target resolution and group fanout so both reject the same shapes
557
+ * before any network or CDB work. Returns the normalized attribute selection.
558
+ */
559
+ export function validateRetrieveRequestShape(
560
+ request: RetrieveRequest,
561
+ ): readonly string[] {
562
+ if (!request.path.startsWith("/")) {
563
+ throw new CentrsError({
564
+ code: "input/invalid-routeros-path",
565
+ summary: `RouterOS path must be slash-prefixed. Received: ${request.path}`,
566
+ remediation:
567
+ "Pass a RouterOS menu path such as `/system/resource` or `/ip/address`.",
568
+ context: { path: request.path },
569
+ });
570
+ }
571
+
572
+ if (request.filter !== undefined || request.query !== undefined) {
573
+ throw new CentrsError({
574
+ code: "validation/not-implemented",
575
+ summary:
576
+ "`--filter` and `--query` are not implemented yet for `retrieve`.",
577
+ remediation:
578
+ "Remove the flag for now, or narrow the request with `--attribute` and `--max-results` instead.",
579
+ context: {
580
+ filter: request.filter,
581
+ query: request.query,
582
+ },
583
+ });
584
+ }
585
+
586
+ const attributeSelections = normalizeAttributeSelection(request);
587
+ if (request.allAttributes && attributeSelections.length > 0) {
588
+ throw new CentrsError({
589
+ code: "usage/conflicting-flags",
590
+ summary:
591
+ "`--all-attributes` cannot be combined with `--attribute` or `--attributes`.",
592
+ remediation:
593
+ "Choose either an explicit projection (`--attribute`) or the full detail/all-attributes shape (`--all-attributes`).",
594
+ });
595
+ }
596
+
597
+ if (
598
+ request.listAttributes &&
599
+ (request.allAttributes || attributeSelections.length > 0)
600
+ ) {
601
+ throw new CentrsError({
602
+ code: "usage/conflicting-flags",
603
+ summary:
604
+ "`--list-attributes` cannot be combined with output-shaping flags such as `--attribute` or `--all-attributes`.",
605
+ remediation:
606
+ "Run `--list-attributes` by itself, then make a second call with the attributes you want to retrieve.",
607
+ });
608
+ }
609
+
610
+ return attributeSelections;
611
+ }
612
+
613
+ /**
614
+ * Build a {@link ResolvedRetrieveRequest} from the static request plus an
615
+ * already-resolved CDB record (or `undefined` for a literal target). Fanout
616
+ * reuses this per group member after loading + decrypting the CDB once, so the
617
+ * resolver settings ladder (env / cli / comment-kv) applies identically to a
618
+ * single target and to every target in a group.
619
+ */
620
+ export function buildResolvedRetrieve(
621
+ request: RetrieveRequest,
622
+ env: Record<string, string | undefined>,
623
+ cdbResolution: CdbResolution | undefined,
624
+ attributeSelections: readonly string[],
625
+ macResolution?: { mac: string; ip: string },
626
+ ): ResolvedRetrieveRequest {
627
+ const via = resolveProtocol(request, env, cdbResolution);
628
+ const format = resolveFormat(request, env);
629
+ const validate = resolveBooleanSetting(
630
+ request.validate,
631
+ env,
632
+ "CENTRS_VALIDATE",
633
+ true,
634
+ "validate",
635
+ cdbResolution?.overrides.validate,
636
+ );
637
+ const timeoutMs = resolveTimeoutSetting(
638
+ request.timeout,
639
+ env,
640
+ via.value,
641
+ cdbResolution?.overrides.timeoutMs,
642
+ );
643
+ const maxResultsBytes = resolveOptionalIntegerSetting(
644
+ request.maxResultsBytes,
645
+ env,
646
+ "CENTRS_MAX_RESULTS",
647
+ "max-results",
648
+ );
649
+ const target = resolveTarget(
650
+ {
651
+ targetInput: request.targetInput,
652
+ host: request.host,
653
+ port: request.port,
654
+ macResolution,
655
+ },
656
+ env,
657
+ via.value,
658
+ cdbResolution,
659
+ );
660
+ const auth = resolveAuth(
661
+ { username: request.username, password: request.password },
662
+ env,
663
+ cdbResolution,
664
+ );
665
+
666
+ return {
667
+ path: request.path,
668
+ via,
669
+ target,
670
+ auth,
671
+ timeoutMs,
672
+ format,
673
+ validate,
674
+ maxResultsBytes,
675
+ attributes: attributeSelections,
676
+ allAttributes: request.allAttributes ?? false,
677
+ listAttributes: request.listAttributes ?? false,
678
+ verbose: request.verbose ?? false,
679
+ warnings: cdbResolution?.warnings ?? [],
680
+ };
681
+ }
682
+
683
+ export interface RetrieveGlobalContext {
684
+ via: RouterOsProtocol;
685
+ summary: RetrieveRequestSummary;
686
+ settings: CommonSettingsMeta;
687
+ }
688
+
689
+ /**
690
+ * Resolve the target-independent settings (protocol, format, validate, timeout,
691
+ * max-results) used for a group fanout's outer `meta.operation.request` summary
692
+ * and its transport-aware concurrency default. No CDB record is consulted; the
693
+ * global `--via` (default `rest-api`) decides the summary and concurrency base.
694
+ */
695
+ export function resolveRetrieveGlobalContext(
696
+ request: RetrieveRequest,
697
+ env: Record<string, string | undefined>,
698
+ attributeSelections: readonly string[],
699
+ ): RetrieveGlobalContext {
700
+ const via = resolveProtocol(request, env);
701
+ const format = resolveFormat(request, env);
702
+ const validate = resolveBooleanSetting(
703
+ request.validate,
704
+ env,
705
+ "CENTRS_VALIDATE",
706
+ true,
707
+ "validate",
708
+ );
709
+ const timeoutMs = resolveTimeoutSetting(request.timeout, env, via.value);
710
+ const maxResultsBytes = resolveOptionalIntegerSetting(
711
+ request.maxResultsBytes,
712
+ env,
713
+ "CENTRS_MAX_RESULTS",
714
+ "max-results",
715
+ );
716
+
717
+ return {
718
+ via: via.value,
719
+ summary: {
720
+ path: request.path,
721
+ attributes: attributeSelections,
722
+ allAttributes: request.allAttributes ?? false,
723
+ listAttributes: request.listAttributes ?? false,
724
+ validate: validate.value,
725
+ verbose: request.verbose ?? false,
726
+ timeoutMs: timeoutMs.value,
727
+ format: format.value,
728
+ maxResultsBytes: maxResultsBytes?.value,
729
+ },
730
+ settings: {
731
+ via: toCoreSource(via.source),
732
+ timeoutMs: toCoreSource(timeoutMs.source),
733
+ format: toCoreSource(format.source),
734
+ validate: toCoreSource(validate.source),
735
+ maxResultsBytes: maxResultsBytes
736
+ ? toCoreSource(maxResultsBytes.source)
737
+ : undefined,
738
+ },
739
+ };
740
+ }
741
+
742
+ export async function resolveRetrieveRequest(
743
+ request: RetrieveRequest,
744
+ env: Record<string, string | undefined>,
745
+ ): Promise<ResolvedRetrieveRequest> {
746
+ const attributeSelections = validateRetrieveRequestShape(request);
747
+
748
+ const cdbResolution = await resolveCdb(
749
+ {
750
+ targetInput: request.targetInput,
751
+ cdbFile: request.cdbFile,
752
+ cdbPassword: request.cdbPassword,
753
+ },
754
+ env,
755
+ );
756
+
757
+ const macResolution = await resolveMacForRetrieve(
758
+ request,
759
+ env,
760
+ cdbResolution,
761
+ );
762
+
763
+ return buildResolvedRetrieve(
764
+ request,
765
+ env,
766
+ cdbResolution,
767
+ attributeSelections,
768
+ macResolution,
769
+ );
770
+ }
771
+
772
+ /**
773
+ * Resolve a MAC target to an IP for IP-based transports, honoring the resolve
774
+ * policy. Returns `undefined` for L2 transports (mac-telnet/romon) or when the
775
+ * target is not a MAC. Shared by single-target and group-fanout paths so
776
+ * `--resolve arp` works for CDB group members too.
777
+ */
778
+ export async function resolveMacForRetrieve(
779
+ request: RetrieveRequest,
780
+ env: Record<string, string | undefined>,
781
+ cdb?: CdbResolution,
782
+ ): Promise<{ mac: string; ip: string } | undefined> {
783
+ if (!isIpTransport(resolveProtocol(request, env, cdb).value)) {
784
+ return undefined;
785
+ }
786
+ return resolveMacTarget({
787
+ host: request.host,
788
+ targetInput: request.targetInput,
789
+ cdbTarget: cdb?.target,
790
+ env,
791
+ policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
792
+ operation: "retrieve",
793
+ });
794
+ }
795
+
796
+ function resolveProtocol(
797
+ request: RetrieveRequest,
798
+ env: Record<string, string | undefined>,
799
+ cdb?: CdbResolution,
800
+ ): ResolvedSetting<RouterOsProtocol> {
801
+ const via = resolveStringSetting(
802
+ request.via,
803
+ env,
804
+ "CENTRS_VIA",
805
+ "rest-api",
806
+ "via",
807
+ undefined,
808
+ cdb?.overrides.via,
809
+ );
810
+ if (!via) {
811
+ throw new CentrsError({
812
+ code: "internal/unhandled",
813
+ summary: "Failed to resolve the default retrieve protocol.",
814
+ remediation:
815
+ "Report this bug; retrieve should default to `rest-api` when no protocol is pinned.",
816
+ });
817
+ }
818
+
819
+ if (
820
+ ![
821
+ "rest-api",
822
+ "native-api",
823
+ "ssh",
824
+ "snmp",
825
+ "mndp",
826
+ "mac-telnet",
827
+ "romon",
828
+ "winbox-terminal",
829
+ ].includes(via.value)
830
+ ) {
831
+ throw new CentrsError({
832
+ code: "settings/invalid-via",
833
+ summary: `Unsupported protocol identifier: ${via.value}`,
834
+ remediation:
835
+ "Choose one of the known `via` values, such as `rest-api` for the current alpha retrieve loop.",
836
+ context: { via: via.value },
837
+ });
838
+ }
839
+
840
+ const plan = getProtocolPlan(via.value as RouterOsProtocol);
841
+ if (!plan.capabilities.includes("retrieve")) {
842
+ throw new CentrsError({
843
+ code: "routeros/unsupported-capability",
844
+ summary: `Protocol ${via.value} does not support the retrieve capability.`,
845
+ remediation:
846
+ "Choose a retrieve-capable protocol such as `rest-api`, `native-api`, or `snmp`.",
847
+ context: { via: via.value, capability: "retrieve" },
848
+ });
849
+ }
850
+
851
+ if (!plan.implemented) {
852
+ throw new CentrsError({
853
+ code: "routeros/protocol-not-implemented",
854
+ summary: `Protocol ${via.value} is planned but not implemented yet.`,
855
+ remediation:
856
+ "Use `--via rest-api` for the current alpha retrieve implementation.",
857
+ context: { via: via.value, capability: "retrieve" },
858
+ });
859
+ }
860
+
861
+ return via as ResolvedSetting<RouterOsProtocol>;
862
+ }
863
+
864
+ function resolveFormat(
865
+ request: RetrieveRequest,
866
+ env: Record<string, string | undefined>,
867
+ ): ResolvedSetting<RetrieveOutputFormat> {
868
+ return resolveStringSetting(
869
+ request.format,
870
+ env,
871
+ "CENTRS_FORMAT",
872
+ "text",
873
+ "format",
874
+ parseOutputFormat,
875
+ ) as ResolvedSetting<RetrieveOutputFormat>;
876
+ }
877
+
878
+ /**
879
+ * Path-existence probe. An invalid path raises a native trap that
880
+ * {@link mapRouterOsError} normalizes to `routeros/unknown-path` (REST instead
881
+ * returns an empty child list). Both the legacy `routeros/api-trap` catch-all
882
+ * and the grounded `routeros/unknown-path` classification are flattened to an
883
+ * empty list so the orchestrator surfaces a single `validation/unknown-path`.
884
+ * Traps during attribute/completion discovery do NOT use this probe — they
885
+ * surface as-is.
886
+ */
887
+ async function inspectChildrenForProbe(
888
+ backend: ProtocolAdapter,
889
+ path: string,
890
+ ): Promise<unknown[]> {
891
+ try {
892
+ return await backend.inspect("child", path);
893
+ } catch (error) {
894
+ if (
895
+ error instanceof CentrsError &&
896
+ (error.code === "routeros/api-trap" ||
897
+ error.code === "routeros/unknown-path")
898
+ ) {
899
+ return [];
900
+ }
901
+ throw error;
902
+ }
903
+ }
904
+
905
+ async function inspectRetrievePath(
906
+ resolved: ResolvedRetrieveRequest,
907
+ backend: ProtocolAdapter,
908
+ ): Promise<RetrieveInspection> {
909
+ const pathTokens = pathTokensForInspect(resolved.path);
910
+ const rootChildren = (await inspectChildrenForProbe(
911
+ backend,
912
+ pathToInspectString(pathTokens),
913
+ )) as InspectChildItem[];
914
+
915
+ const supportsPrint = rootChildren.some((child) =>
916
+ isCommandNode(child, "print"),
917
+ );
918
+ const supportsGet = rootChildren.some((child) => isCommandNode(child, "get"));
919
+ if (!supportsPrint && !supportsGet) {
920
+ throw new CentrsError({
921
+ code: "validation/unknown-path",
922
+ summary: `RouterOS path ${resolved.path} does not expose a retrieve command.`,
923
+ remediation:
924
+ "Check the slash-prefixed RouterOS path, or use a known readable path such as `/system/resource`, `/system/identity`, `/ip/address`, or `/interface`.",
925
+ context: {
926
+ path: resolved.path,
927
+ validationSource: "/console/inspect request=child",
928
+ availableChildren: rootChildren
929
+ .map((child) => child.name)
930
+ .filter((name): name is string => typeof name === "string"),
931
+ },
932
+ });
933
+ }
934
+
935
+ const singleton = isKnownSingletonPath(resolved.path);
936
+ return {
937
+ command: singleton && supportsGet ? "get" : "print",
938
+ };
939
+ }
940
+
941
+ async function inspectAttributes(
942
+ resolved: ResolvedRetrieveRequest,
943
+ inspection: RetrieveInspection,
944
+ backend: ProtocolAdapter,
945
+ ): Promise<string[]> {
946
+ const pathTokens = pathTokensForInspect(resolved.path);
947
+ const argument = inspection.command === "get" ? "value-name" : "proplist";
948
+ const completionRows = (await backend.inspect(
949
+ "completion",
950
+ pathToInspectString([...pathTokens, inspection.command, argument]),
951
+ )) as InspectCompletionItem[];
952
+ const completions = extractCompletionNames(completionRows);
953
+ if (completions.length > 0) {
954
+ return completions;
955
+ }
956
+
957
+ const commandChildren = (await backend.inspect(
958
+ "child",
959
+ pathToInspectString([...pathTokens, inspection.command]),
960
+ )) as InspectChildItem[];
961
+ return commandChildren
962
+ .filter(isArgumentNode)
963
+ .map((child) => child.name)
964
+ .filter(
965
+ (name): name is string => typeof name === "string" && name.length > 0,
966
+ )
967
+ .sort();
968
+ }
969
+
970
+ async function executeRetrieve(
971
+ resolved: ResolvedRetrieveRequest,
972
+ backend: ProtocolAdapter,
973
+ ): Promise<unknown> {
974
+ if (isKnownSingletonPath(resolved.path)) {
975
+ const data = await backend.getSingleton(resolved.path);
976
+ if (resolved.attributes.length > 0) {
977
+ return projectSingletonAttributes(data, resolved.attributes);
978
+ }
979
+ return data;
980
+ }
981
+
982
+ return backend.list(resolved.path, {
983
+ proplist: resolved.attributes.length > 0 ? resolved.attributes : undefined,
984
+ detail: resolved.allAttributes,
985
+ });
986
+ }
987
+
988
+ function pathTokensForInspect(path: string): string[] {
989
+ return path.split("/").filter(Boolean);
990
+ }
991
+
992
+ function isCommandNode(
993
+ child: InspectChildItem,
994
+ name: "get" | "print",
995
+ ): boolean {
996
+ return (
997
+ child.name === name &&
998
+ (child.type === "cmd" || child["node-type"] === "cmd")
999
+ );
1000
+ }
1001
+
1002
+ function isArgumentNode(child: InspectChildItem): boolean {
1003
+ return child.type === "arg" || child["node-type"] === "arg";
1004
+ }
1005
+
1006
+ function extractCompletionNames(
1007
+ rows: readonly InspectCompletionItem[],
1008
+ ): string[] {
1009
+ const names = rows
1010
+ .flatMap((row) => [row.completion, row.name, row.value, row.text])
1011
+ .filter((value): value is string => typeof value === "string")
1012
+ .map((value) => value.replace(/=.*$/, "").trim())
1013
+ .filter((value) => value.length > 0);
1014
+ return [...new Set(names)].sort();
1015
+ }
1016
+
1017
+ function isKnownSingletonPath(path: string): boolean {
1018
+ return ["/system/resource", "/system/identity"].includes(
1019
+ path.replace(/\/$/, ""),
1020
+ );
1021
+ }
1022
+
1023
+ function projectSingletonAttributes(
1024
+ data: unknown,
1025
+ attributes: readonly string[],
1026
+ ): unknown {
1027
+ if (!isPlainObject(data)) {
1028
+ return data;
1029
+ }
1030
+ if (attributes.length === 1) {
1031
+ return data[attributes[0] ?? ""];
1032
+ }
1033
+
1034
+ const projected: Record<string, unknown> = {};
1035
+ for (const attribute of attributes) {
1036
+ projected[attribute] = data[attribute];
1037
+ }
1038
+ return projected;
1039
+ }
1040
+
1041
+ function resolveTimeoutSetting(
1042
+ timeout: RetrieveRequest["timeout"],
1043
+ env: Record<string, string | undefined>,
1044
+ via: RouterOsProtocol,
1045
+ commentKv?: ResolvedSetting<number>,
1046
+ ): ResolvedSetting<number> {
1047
+ const resolved = resolveStringSetting(
1048
+ timeout === undefined ? undefined : String(timeout),
1049
+ env,
1050
+ "CENTRS_TIMEOUT",
1051
+ "10000",
1052
+ "timeout",
1053
+ (value) => {
1054
+ const parsed = parseDuration(value);
1055
+ if (parsed <= 0) {
1056
+ throw new CentrsError({
1057
+ code: "settings/invalid-timeout",
1058
+ summary: `Timeout must be greater than zero. Received: ${value}`,
1059
+ remediation:
1060
+ "Use a positive integer in milliseconds or a suffix like `5s` / `500ms`.",
1061
+ });
1062
+ }
1063
+ return parsed;
1064
+ },
1065
+ commentKv,
1066
+ );
1067
+ if (!resolved) {
1068
+ throw new Error("timeout resolution produced no value");
1069
+ }
1070
+
1071
+ if (via === "rest-api" && resolved.value > 60_000) {
1072
+ throw new CentrsError({
1073
+ code: "usage/timeout-out-of-range",
1074
+ summary: `REST timeout ${resolved.value}ms exceeds the current RouterOS REST ceiling.`,
1075
+ remediation:
1076
+ "Use `--timeout 60s` or less for the current REST retrieve path.",
1077
+ context: {
1078
+ via,
1079
+ timeoutMs: resolved.value,
1080
+ ceilingMs: 60_000,
1081
+ },
1082
+ });
1083
+ }
1084
+
1085
+ return resolved;
1086
+ }
1087
+
1088
+ function normalizeAttributeSelection(request: RetrieveRequest): string[] {
1089
+ const selections = [
1090
+ ...normalizeAttributes(request.attribute),
1091
+ ...normalizeAttributes(request.attributes),
1092
+ ];
1093
+ return [...new Set(selections)];
1094
+ }
1095
+
1096
+ function normalizeAttributes(
1097
+ input: string | readonly string[] | undefined,
1098
+ ): string[] {
1099
+ if (input === undefined) {
1100
+ return [];
1101
+ }
1102
+
1103
+ if (Array.isArray(input)) {
1104
+ return input.flatMap((value) => normalizeAttributes(value));
1105
+ }
1106
+
1107
+ return String(input)
1108
+ .split(",")
1109
+ .map((value: string) => value.trim())
1110
+ .filter((value: string) => value.length > 0);
1111
+ }
1112
+
1113
+ function parseOutputFormat(value: string): RetrieveOutputFormat {
1114
+ if (retrieveOutputFormats.includes(value as RetrieveOutputFormat)) {
1115
+ return value as RetrieveOutputFormat;
1116
+ }
1117
+
1118
+ throw new CentrsError({
1119
+ code: "settings/invalid-format",
1120
+ summary: `Unsupported output format: ${value}`,
1121
+ remediation: `Choose one of ${retrieveOutputFormats.join(", ")}.`,
1122
+ });
1123
+ }
1124
+
1125
+ function pathToInspectString(path: readonly string[]): string {
1126
+ return path.join(",");
1127
+ }
1128
+
1129
+ function countResultObjects(data: unknown): number {
1130
+ if (Array.isArray(data)) {
1131
+ return data.length;
1132
+ }
1133
+ if (data === null || data === undefined) {
1134
+ return 0;
1135
+ }
1136
+ return 1;
1137
+ }
1138
+
1139
+ function byteLength(text: string): number {
1140
+ return new TextEncoder().encode(text).length;
1141
+ }
1142
+
1143
+ function formatCoreSource(source: CoreSettingSource | undefined): string {
1144
+ if (!source) {
1145
+ return "unset";
1146
+ }
1147
+ return source.key ? `${source.kind}:${source.key}` : source.kind;
1148
+ }
1149
+
1150
+ function exhaustiveOutputFormat(value: never): never {
1151
+ throw new Error(`Unhandled retrieve output format: ${String(value)}`);
1152
+ }
1153
+
1154
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
1155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1156
+ }
1157
+
1158
+ export function toYaml(value: unknown, indent = 0): string {
1159
+ const prefix = " ".repeat(indent);
1160
+ if (value === null || value === undefined) {
1161
+ return "null";
1162
+ }
1163
+ if (typeof value === "string") {
1164
+ return yamlScalar(value);
1165
+ }
1166
+ if (typeof value === "number" || typeof value === "boolean") {
1167
+ return String(value);
1168
+ }
1169
+ if (Array.isArray(value)) {
1170
+ if (value.length === 0) {
1171
+ return `${prefix}[]`;
1172
+ }
1173
+ return value
1174
+ .map((item) => {
1175
+ if (isScalar(item)) {
1176
+ return `${prefix}- ${toYaml(item, indent + 2)}`;
1177
+ }
1178
+ return `${prefix}-\n${toYaml(item, indent + 2)}`;
1179
+ })
1180
+ .join("\n");
1181
+ }
1182
+ if (isPlainObject(value)) {
1183
+ const entries = Object.entries(value).filter(
1184
+ ([, entryValue]) => entryValue !== undefined,
1185
+ );
1186
+ if (entries.length === 0) {
1187
+ return `${prefix}{}`;
1188
+ }
1189
+ return entries
1190
+ .map(([key, entryValue]) => {
1191
+ if (isScalar(entryValue)) {
1192
+ return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
1193
+ }
1194
+ return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
1195
+ })
1196
+ .join("\n");
1197
+ }
1198
+ return yamlScalar(JSON.stringify(value));
1199
+ }
1200
+
1201
+ function isScalar(value: unknown): value is boolean | number | string | null {
1202
+ return (
1203
+ value === null ||
1204
+ typeof value === "string" ||
1205
+ typeof value === "number" ||
1206
+ typeof value === "boolean"
1207
+ );
1208
+ }
1209
+
1210
+ function yamlScalar(value: string): string {
1211
+ if (value.length === 0) {
1212
+ return '""';
1213
+ }
1214
+ return JSON.stringify(value);
1215
+ }