@tikoci/centrs 0.1.2 → 0.1.3

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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
package/src/terminal.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  type CdbResolution,
32
32
  isIpTransport,
33
33
  isMacAddress,
34
+ loadEnvFileDefaults,
34
35
  parseResolvePolicy,
35
36
  type ResolvedAuth,
36
37
  type ResolvedTarget,
@@ -147,6 +148,7 @@ export async function resolveTerminalRequest(
147
148
  if (preliminaryVia !== undefined) {
148
149
  gateTerminalVia(preliminaryVia);
149
150
  }
151
+ const config = await loadEnvFileDefaults(env);
150
152
  const cdb = await resolveCdb(
151
153
  {
152
154
  targetInput: request.targetInput,
@@ -154,6 +156,7 @@ export async function resolveTerminalRequest(
154
156
  cdbPassword: request.cdbPassword,
155
157
  },
156
158
  env,
159
+ config,
157
160
  );
158
161
  // A MAC target defaults to the L2 console (mac-telnet); any other target
159
162
  // (host / IP / CDB identity) defaults to ssh — the IP-level terminal transport.
@@ -168,6 +171,7 @@ export async function resolveTerminalRequest(
168
171
  "via",
169
172
  undefined,
170
173
  cdb?.overrides.via,
174
+ config,
171
175
  );
172
176
  const viaValue = via?.value ?? defaultVia;
173
177
  // Re-gate on the fully resolved value to catch a CDB comment-kv `via=` override.
@@ -183,7 +187,10 @@ export async function resolveTerminalRequest(
183
187
  targetInput: request.targetInput,
184
188
  cdbTarget: cdb?.target,
185
189
  env,
186
- policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
190
+ config,
191
+ policy: parseResolvePolicy(
192
+ request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
193
+ ),
187
194
  operation: "terminal",
188
195
  })
189
196
  : undefined;
@@ -197,6 +204,7 @@ export async function resolveTerminalRequest(
197
204
  env,
198
205
  viaValue === "ssh" ? "ssh" : "mac-telnet",
199
206
  cdb,
207
+ config,
200
208
  );
201
209
  const auth = resolveAuth(
202
210
  {
@@ -206,6 +214,7 @@ export async function resolveTerminalRequest(
206
214
  },
207
215
  env,
208
216
  cdb,
217
+ config,
209
218
  );
210
219
  const insecure = resolveBooleanSetting(
211
220
  request.insecure,
@@ -214,6 +223,7 @@ export async function resolveTerminalRequest(
214
223
  false,
215
224
  "insecure",
216
225
  cdb?.overrides.insecure,
226
+ config,
217
227
  );
218
228
  return {
219
229
  via: viaValue,
@@ -0,0 +1,527 @@
1
+ /**
2
+ * Multi-target fan-out for `transfer`.
3
+ *
4
+ * A target selection (`--group` / `--where` / `--all` / `--default` / multiple
5
+ * positionals before the verb) runs the SAME transfer verb on each member through
6
+ * the single-target tail (`transfer`), collecting per-target inner
7
+ * `TransferEnvelope`s into the locked `FanoutData` shape (`src/core/envelope.ts`).
8
+ *
9
+ * Boundaries (see `commands/transfer/README.md`, `docs/CONSTITUTION.md`), shared
10
+ * with `api`/`retrieve`/`execute` fan-out:
11
+ * - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved with
12
+ * its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
13
+ * never collides ad-hoc literals.
14
+ * - A `--default` / `__default__` member fails that one target deterministically
15
+ * with `target/unresolved`.
16
+ * - A **mutating** verb (`upload`/`remove`/`mkdir`/`copy`) is confirmed ONCE up
17
+ * front (`--yes`), never per target; without `--yes` the error names the blast
18
+ * radius. `download`/`list` only read the device.
19
+ * - **`download` fan-out requires `--out-dir`**: N devices cannot share one local
20
+ * path, so each target writes a collision-safe file named by its CDB
21
+ * target/identity into the directory.
22
+ */
23
+
24
+ import { basename, extname, join } from "node:path";
25
+ import type {
26
+ CentrsErrorEnvelope,
27
+ CentrsSuccessEnvelope,
28
+ CommonSettingsMeta,
29
+ FanoutData,
30
+ FanoutSummary,
31
+ } from "./core/envelope.ts";
32
+ import {
33
+ buildFanoutEnvelope,
34
+ buildFanoutResolveFailure,
35
+ commonVia,
36
+ defaultFanoutSleep,
37
+ resolveFanoutConcurrency,
38
+ runFanout,
39
+ summarizeFanout,
40
+ } from "./core/fanout.ts";
41
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
42
+ import { promptForWriteConfirmation } from "./execute.ts";
43
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
44
+ import {
45
+ type CdbSelectionExpansion,
46
+ type CdbSelectionMember,
47
+ type CdbSelectionResolveInput,
48
+ expandCdbSelection,
49
+ isDefaultRecordTarget,
50
+ loadEnvFileDefaults,
51
+ type TargetSelection,
52
+ } from "./resolver/index.ts";
53
+ import { toYaml } from "./retrieve.ts";
54
+ import {
55
+ buildTransferErrorEnvelope,
56
+ isTransferWriteVerb,
57
+ type TransferEnvelope,
58
+ type TransferOperationMeta,
59
+ type TransferOutputFormat,
60
+ type TransferRequest,
61
+ type TransferSuccessEnvelope,
62
+ type TransferVerb,
63
+ transfer,
64
+ validateTransferRequestShape,
65
+ } from "./transfer.ts";
66
+
67
+ /** Compact description of the selector that produced the fan-out. */
68
+ export interface TransferSelectionSummary {
69
+ groups: readonly string[];
70
+ where: readonly string[];
71
+ all: boolean;
72
+ default: boolean;
73
+ positionals: readonly string[];
74
+ }
75
+
76
+ export interface TransferFanoutRequestSummary {
77
+ verb: TransferVerb;
78
+ remote: string | null;
79
+ force: boolean;
80
+ validate: boolean;
81
+ outDir: string | null;
82
+ }
83
+
84
+ export interface TransferFanoutOperationMeta {
85
+ kind: "fanout";
86
+ concurrency: number;
87
+ summary: FanoutSummary;
88
+ request: TransferFanoutRequestSummary;
89
+ selection: TransferSelectionSummary;
90
+ }
91
+
92
+ export type TransferFanoutData = FanoutData<unknown, TransferOperationMeta>;
93
+ export type TransferFanoutEnvelope = CentrsSuccessEnvelope<
94
+ TransferFanoutData,
95
+ TransferFanoutOperationMeta
96
+ >;
97
+ export type TransferFanoutErrorEnvelope =
98
+ CentrsErrorEnvelope<TransferFanoutOperationMeta>;
99
+
100
+ /** Test/override seams; production callers pass nothing. */
101
+ export interface TransferFanoutInternals {
102
+ expand?: (
103
+ selection: TargetSelection,
104
+ input: CdbSelectionResolveInput,
105
+ env: Record<string, string | undefined>,
106
+ config?: Record<string, string | undefined>,
107
+ ) => Promise<CdbSelectionExpansion>;
108
+ execute?: (
109
+ request: TransferRequest,
110
+ member: CdbSelectionMember,
111
+ ) => Promise<TransferSuccessEnvelope>;
112
+ sleep?: (ms: number) => Promise<void>;
113
+ }
114
+
115
+ export interface TransferFanoutOptions {
116
+ concurrency?: number;
117
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
118
+ allowAdhoc?: boolean;
119
+ }
120
+
121
+ function summarizeSelection(
122
+ selection: TargetSelection,
123
+ ): TransferSelectionSummary {
124
+ return {
125
+ groups: selection.groups,
126
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
127
+ all: selection.all,
128
+ default: selection.default,
129
+ positionals: selection.positionals,
130
+ };
131
+ }
132
+
133
+ function memberLabel(member: CdbSelectionMember): string {
134
+ return member.kind === "cdb" ? member.resolution.identity : member.input;
135
+ }
136
+
137
+ function memberTargetMeta(member: CdbSelectionMember): {
138
+ input?: string;
139
+ identity?: string;
140
+ recordIndex?: number;
141
+ } {
142
+ if (member.kind === "cdb") {
143
+ return {
144
+ input: member.resolution.identity,
145
+ identity: member.resolution.identity,
146
+ recordIndex: member.recordIndex,
147
+ };
148
+ }
149
+ return { input: member.input };
150
+ }
151
+
152
+ /** Sanitize a device label into a single safe filename segment. */
153
+ function sanitizeFilename(label: string): string {
154
+ // Character-class replaces (no backtracking); strip leading dots so a label
155
+ // cannot produce a hidden / traversal-looking name.
156
+ const cleaned = label.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "");
157
+ return cleaned.length > 0 ? cleaned : "target";
158
+ }
159
+
160
+ /**
161
+ * Pre-compute one collision-safe local path per member for a `download` fan-out.
162
+ * Files are named by the device label (CDB identity / literal input) and keep the
163
+ * remote file's extension; same-label collisions are disambiguated deterministically.
164
+ */
165
+ function planDownloadPaths(
166
+ outDir: string,
167
+ remote: string,
168
+ members: readonly CdbSelectionMember[],
169
+ ): Map<CdbSelectionMember, string> {
170
+ const ext = extname(basename(remote));
171
+ const used = new Set<string>();
172
+ const paths = new Map<CdbSelectionMember, string>();
173
+ members.forEach((member, index) => {
174
+ const label = sanitizeFilename(memberLabel(member));
175
+ let name = `${label}${ext}`;
176
+ if (used.has(name)) {
177
+ const disambiguator =
178
+ member.kind === "cdb" ? `-${member.recordIndex}` : `-${index}`;
179
+ name = `${label}${disambiguator}${ext}`;
180
+ let bump = 1;
181
+ while (used.has(name)) {
182
+ name = `${label}${disambiguator}-${bump}${ext}`;
183
+ bump += 1;
184
+ }
185
+ }
186
+ used.add(name);
187
+ paths.set(member, join(outDir, name));
188
+ });
189
+ return paths;
190
+ }
191
+
192
+ /**
193
+ * Ad-hoc literal targets have no CDB record. The resolver may borrow the
194
+ * `__default__` record's index for fallback creds, so the inner envelope would
195
+ * carry that borrowed `recordIndex` — making distinct literals collide on one
196
+ * index. Drop it for literal members and stamp the caller's `input`.
197
+ */
198
+ function relabelAdhocMember(
199
+ envelope: TransferEnvelope,
200
+ member: CdbSelectionMember,
201
+ ): TransferEnvelope {
202
+ if (member.kind !== "literal") {
203
+ return envelope;
204
+ }
205
+ const { recordIndex: _borrowed, ...target } = envelope.meta.target;
206
+ return {
207
+ ...envelope,
208
+ meta: { ...envelope.meta, target: { ...target, input: member.input } },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Stamp the member's identity onto a per-target ENVELOPE. `buildTransferErrorEnvelope`
214
+ * only knows `request.targetInput`, so a failed cdb member would otherwise lose its
215
+ * `recordIndex`/`identity` (breaking record-index ordering). Literals are relabeled
216
+ * to drop a borrowed `__default__` index; cdb members get their record meta restored.
217
+ */
218
+ function applyMemberMeta(
219
+ envelope: TransferEnvelope,
220
+ member: CdbSelectionMember,
221
+ ): TransferEnvelope {
222
+ if (member.kind === "literal") {
223
+ return relabelAdhocMember(envelope, member);
224
+ }
225
+ return {
226
+ ...envelope,
227
+ meta: {
228
+ ...envelope.meta,
229
+ target: { ...envelope.meta.target, ...memberTargetMeta(member) },
230
+ },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Run a `transfer` fan-out. Returns a success envelope when the orchestration
236
+ * completes (even if every target failed); throws only for pre-flight failures
237
+ * (bad request shape, missing `--out-dir`, CDB decrypt, unconfirmed write) the
238
+ * caller renders as an outer error via {@link buildTransferFanoutErrorEnvelope}.
239
+ */
240
+ export async function transferFanout(
241
+ request: TransferRequest,
242
+ selection: TargetSelection,
243
+ env: Record<string, string | undefined> = Bun.env,
244
+ internals: TransferFanoutInternals = {},
245
+ options: TransferFanoutOptions = {},
246
+ ): Promise<TransferFanoutEnvelope> {
247
+ validateTransferRequestShape(request);
248
+ const config = await loadEnvFileDefaults(env);
249
+ const selectionSummary = summarizeSelection(selection);
250
+ const requestSummary: TransferFanoutRequestSummary = {
251
+ verb: request.verb,
252
+ remote: request.remote ?? request.path ?? null,
253
+ force: request.force ?? false,
254
+ validate: request.validate ?? true,
255
+ outDir: request.outDir ?? null,
256
+ };
257
+
258
+ // `download` fan-out needs a per-target local path: `--out-dir` is mandatory
259
+ // (N devices cannot share one local path).
260
+ if (request.verb === "download" && request.outDir === undefined) {
261
+ throw new CentrsError({
262
+ code: "usage/conflicting-flags",
263
+ summary:
264
+ "`download` fan-out writes one file per target and requires `--out-dir <dir>`.",
265
+ remediation:
266
+ "Pass `--out-dir <dir>`; each target's file is written there, named by its CDB identity.",
267
+ context: { verb: "download" },
268
+ });
269
+ }
270
+
271
+ const expand = internals.expand ?? expandCdbSelection;
272
+ const expansion = await expand(
273
+ selection,
274
+ {
275
+ cdbFile: request.cdbFile,
276
+ cdbPassword: request.cdbPassword,
277
+ allowAdhoc: options.allowAdhoc ?? true,
278
+ },
279
+ env,
280
+ config,
281
+ );
282
+
283
+ const concurrency = resolveFanoutConcurrency(options.concurrency, "rest-api");
284
+
285
+ if (expansion.empty) {
286
+ return transferFanoutEnvelope({
287
+ selection: selectionSummary,
288
+ concurrency,
289
+ summary: { total: 0, ok: 0, failed: 0 },
290
+ requestSummary,
291
+ via: null,
292
+ warnings: expansion.warnings,
293
+ targets: [],
294
+ });
295
+ }
296
+
297
+ // Mutating verbs (upload/remove/mkdir/copy): confirm ONCE up front.
298
+ if (isTransferWriteVerb(request.verb)) {
299
+ await assertFanoutWriteConfirmed(request, expansion);
300
+ }
301
+
302
+ const downloadPaths =
303
+ request.verb === "download"
304
+ ? planDownloadPaths(
305
+ request.outDir ?? ".",
306
+ request.remote ?? "",
307
+ expansion.targets,
308
+ )
309
+ : undefined;
310
+
311
+ const execute =
312
+ internals.execute ??
313
+ ((req: TransferRequest, member: CdbSelectionMember) =>
314
+ transfer(req, env, {
315
+ // cdb members carry their pre-resolved record (CDB loaded once);
316
+ // literals fall through to single-target resolution.
317
+ cdbResolution: member.kind === "cdb" ? member.resolution : undefined,
318
+ }));
319
+ const sleep = internals.sleep ?? defaultFanoutSleep;
320
+
321
+ const targets = await runFanout<
322
+ CdbSelectionMember,
323
+ { request: TransferRequest; member: CdbSelectionMember },
324
+ TransferEnvelope
325
+ >({
326
+ members: expansion.targets,
327
+ concurrency,
328
+ resolve: (member) => {
329
+ if (
330
+ member.kind === "cdb" &&
331
+ isDefaultRecordTarget(member.resolution.target)
332
+ ) {
333
+ throw new CentrsError({
334
+ code: "target/unresolved",
335
+ summary:
336
+ "The `__default__` record is a credential fallback, not a connectable router.",
337
+ remediation:
338
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
339
+ context: { target: member.resolution.target },
340
+ });
341
+ }
342
+ const targetInput =
343
+ member.kind === "cdb" ? member.resolution.target : member.input;
344
+ const perRequest: TransferRequest = { ...request, targetInput };
345
+ if (downloadPaths !== undefined) {
346
+ perRequest.local = downloadPaths.get(member);
347
+ }
348
+ return { request: perRequest, member };
349
+ },
350
+ onResolveError: (member, error) =>
351
+ buildFanoutResolveFailure<TransferOperationMeta>({
352
+ error,
353
+ target: memberTargetMeta(member),
354
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
355
+ summary: "Failed to resolve a fanout target.",
356
+ }),
357
+ execute: async (resolved, member) =>
358
+ relabelAdhocMember(await execute(resolved.request, member), member),
359
+ onExecuteError: (resolved, member, error) =>
360
+ applyMemberMeta(
361
+ buildTransferErrorEnvelope(resolved.request, error),
362
+ member,
363
+ ),
364
+ sleep,
365
+ });
366
+
367
+ return transferFanoutEnvelope({
368
+ selection: selectionSummary,
369
+ concurrency,
370
+ summary: summarizeFanout(targets),
371
+ requestSummary,
372
+ via: commonVia(targets),
373
+ warnings: expansion.warnings,
374
+ targets,
375
+ });
376
+ }
377
+
378
+ async function assertFanoutWriteConfirmed(
379
+ request: TransferRequest,
380
+ expansion: CdbSelectionExpansion,
381
+ ): Promise<void> {
382
+ if (request.yes) {
383
+ return;
384
+ }
385
+ const count = expansion.targets.length;
386
+ const prompt = `Run mutating \`${request.verb}\` across ${count} router(s)? Type yes to continue: `;
387
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
388
+ const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
389
+ prompt,
390
+ );
391
+ if (confirmed) {
392
+ return;
393
+ }
394
+ }
395
+ throw new CentrsError({
396
+ code: "usage/confirmation-required",
397
+ summary: `A mutating \`${request.verb}\` would run across ${count} router(s) and needs explicit confirmation.`,
398
+ remediation:
399
+ "Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
400
+ context: { verb: request.verb, routers: count },
401
+ });
402
+ }
403
+
404
+ interface TransferFanoutEnvelopeInput {
405
+ selection: TransferSelectionSummary;
406
+ concurrency: number;
407
+ summary: FanoutSummary;
408
+ requestSummary: TransferFanoutRequestSummary;
409
+ via: RouterOsProtocol | string | null;
410
+ warnings: CdbSelectionExpansion["warnings"];
411
+ targets: readonly TransferEnvelope[];
412
+ }
413
+
414
+ function transferFanoutEnvelope(
415
+ input: TransferFanoutEnvelopeInput,
416
+ ): TransferFanoutEnvelope {
417
+ const settings: CommonSettingsMeta = {};
418
+ return buildFanoutEnvelope<
419
+ unknown,
420
+ TransferOperationMeta,
421
+ TransferFanoutOperationMeta
422
+ >({
423
+ summary: input.summary,
424
+ targets: input.targets,
425
+ warnings: input.warnings,
426
+ settings,
427
+ via: (input.via as RouterOsProtocol | null) ?? null,
428
+ operation: {
429
+ kind: "fanout",
430
+ concurrency: input.concurrency,
431
+ summary: input.summary,
432
+ request: input.requestSummary,
433
+ selection: input.selection,
434
+ },
435
+ });
436
+ }
437
+
438
+ /** Outer error envelope for a fan-out that failed before per-target results. */
439
+ export function buildTransferFanoutErrorEnvelope(
440
+ request: TransferRequest,
441
+ error: unknown,
442
+ ): TransferFanoutErrorEnvelope {
443
+ const centrsError =
444
+ error instanceof CentrsError
445
+ ? error
446
+ : new CentrsError({
447
+ code: "internal/unhandled",
448
+ summary: "transfer fan-out failed with an unexpected error.",
449
+ remediation:
450
+ "Re-run with `--format json` to capture the structured error details.",
451
+ cause: error,
452
+ });
453
+ const requestedVia = plannedProtocols.includes(
454
+ request.via as RouterOsProtocol,
455
+ )
456
+ ? (request.via as RouterOsProtocol)
457
+ : null;
458
+ return {
459
+ ok: false,
460
+ error: serializeCentrsError(centrsError),
461
+ warnings: [],
462
+ tips: [],
463
+ meta: {
464
+ target: {},
465
+ via: requestedVia,
466
+ settings: {},
467
+ },
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Render a transfer fan-out envelope. `json`/`yaml` serialize the whole envelope;
473
+ * `text` renders a summary line plus one line per target in record-index order.
474
+ */
475
+ export function renderTransferFanoutEnvelope(
476
+ envelope: TransferFanoutEnvelope | TransferFanoutErrorEnvelope,
477
+ format: TransferOutputFormat,
478
+ options: { verbose?: boolean } = {},
479
+ ): string {
480
+ if (format === "json") {
481
+ return JSON.stringify(envelope, null, 2);
482
+ }
483
+ if (format === "yaml") {
484
+ return toYaml(envelope);
485
+ }
486
+ if (!envelope.ok) {
487
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
488
+ if (envelope.error.remediation) {
489
+ lines.push(`Fix: ${envelope.error.remediation}`);
490
+ }
491
+ return lines.join("\n");
492
+ }
493
+ const operation = envelope.meta.operation;
494
+ const { total, ok, failed } = envelope.data.summary;
495
+ const remote = operation?.request.remote ?? "";
496
+ const lines: string[] = [
497
+ `transfer ${operation?.request.verb ?? "?"} ${remote}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
498
+ ];
499
+ for (const target of envelope.data.targets) {
500
+ const meta = target.meta;
501
+ const label =
502
+ meta.target.identity ??
503
+ meta.target.host ??
504
+ meta.target.input ??
505
+ "(unknown)";
506
+ const index = meta.target.recordIndex ?? "-";
507
+ if (target.ok) {
508
+ const data = Array.isArray(target.data) ? undefined : target.data;
509
+ const detail =
510
+ data && typeof data === "object" && "local" in data && data.local
511
+ ? ` → ${String((data as { local?: string }).local)}`
512
+ : "";
513
+ lines.push(` [${index}] ok ${label}${detail}`);
514
+ } else {
515
+ lines.push(
516
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
517
+ );
518
+ if (options.verbose && target.error.remediation) {
519
+ lines.push(` Fix: ${target.error.remediation}`);
520
+ }
521
+ }
522
+ }
523
+ for (const warning of envelope.warnings) {
524
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
525
+ }
526
+ return lines.join("\n");
527
+ }