@tikoci/centrs 0.1.2 → 0.1.4

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 (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Multi-target fan-out for `execute`.
3
+ *
4
+ * A target selection (`--group` / `--where` / `--all` / `--default` / multiple
5
+ * positionals before `--`) expands to N members and runs the SAME RouterOS
6
+ * command on each through the single-target execute tail (`resolveExecuteRequest`
7
+ * → `runResolvedExecute`), collecting per-target inner `ExecuteEnvelope`s into the
8
+ * locked `FanoutData` shape (`src/core/envelope.ts`).
9
+ *
10
+ * Boundaries (see `commands/execute/README.md`, `docs/CONSTITUTION.md`), shared
11
+ * with `api-fanout.ts` / `retrieve-fanout.ts`:
12
+ * - The CDB is loaded ONCE (`expandSelection`); a cdb member is resolved with
13
+ * its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
14
+ * never collides ad-hoc literals.
15
+ * - A `--default` / `__default__` member is guarded from being dialed as the
16
+ * literal hostname `"__default__"` — it fails that one target deterministically
17
+ * with `target/unresolved`.
18
+ * - A **write-shaped** command (add/set/remove) is confirmed ONCE up front
19
+ * (`--yes`), never per target; without `--yes` the error names the blast radius
20
+ * (how many routers). Write-ness is target-independent (`canonicalizeExecuteCommand`).
21
+ */
22
+
23
+ import type {
24
+ CentrsErrorEnvelope,
25
+ CentrsSuccessEnvelope,
26
+ CommonSettingsMeta,
27
+ FanoutData,
28
+ FanoutSummary,
29
+ } from "./core/envelope.ts";
30
+ import {
31
+ buildFanoutEnvelope,
32
+ buildFanoutResolveFailure,
33
+ commonVia,
34
+ defaultFanoutSleep,
35
+ resolveFanoutConcurrency,
36
+ runFanout,
37
+ summarizeFanout,
38
+ } from "./core/fanout.ts";
39
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
40
+ import {
41
+ buildExecuteErrorEnvelopeFromResolved,
42
+ type ExecuteEnvelope,
43
+ type ExecuteOperationMeta,
44
+ type ExecuteRequest,
45
+ type ExecuteRequestSummary,
46
+ type ExecuteSuccessEnvelope,
47
+ promptForWriteConfirmation,
48
+ type ResolvedExecuteRequest,
49
+ resolveExecuteGlobalContext,
50
+ resolveExecuteRequest,
51
+ runResolvedExecute,
52
+ } from "./execute.ts";
53
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
54
+ import {
55
+ expandSelection,
56
+ isDefaultRecordTarget,
57
+ loadEnvFileDefaults,
58
+ type SelectionExpansion,
59
+ type SelectionMember,
60
+ type SelectionResolveInput,
61
+ type TargetSelection,
62
+ } from "./resolver/index.ts";
63
+ import { toYaml } from "./retrieve.ts";
64
+
65
+ /** Compact description of the selector that produced the fan-out. */
66
+ export interface ExecuteSelectionSummary {
67
+ groups: readonly string[];
68
+ where: readonly string[];
69
+ all: boolean;
70
+ default: boolean;
71
+ positionals: readonly string[];
72
+ /** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
73
+ quickchr?: readonly string[];
74
+ }
75
+
76
+ export interface ExecuteFanoutOperationMeta {
77
+ kind: "fanout";
78
+ concurrency: number;
79
+ summary: FanoutSummary;
80
+ request: ExecuteRequestSummary;
81
+ selection: ExecuteSelectionSummary;
82
+ }
83
+
84
+ export type ExecuteFanoutData = FanoutData<unknown, ExecuteOperationMeta>;
85
+ export type ExecuteFanoutEnvelope = CentrsSuccessEnvelope<
86
+ ExecuteFanoutData,
87
+ ExecuteFanoutOperationMeta
88
+ >;
89
+ export type ExecuteFanoutErrorEnvelope =
90
+ CentrsErrorEnvelope<ExecuteFanoutOperationMeta>;
91
+
92
+ /** Test/override seams; production callers pass nothing. */
93
+ export interface ExecuteFanoutInternals {
94
+ expand?: (
95
+ selection: TargetSelection,
96
+ input: SelectionResolveInput,
97
+ env: Record<string, string | undefined>,
98
+ config?: Record<string, string | undefined>,
99
+ ) => Promise<SelectionExpansion>;
100
+ execute?: (
101
+ resolved: ResolvedExecuteRequest,
102
+ ) => Promise<ExecuteSuccessEnvelope>;
103
+ sleep?: (ms: number) => Promise<void>;
104
+ }
105
+
106
+ export interface ExecuteFanoutOptions {
107
+ concurrency?: number;
108
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
109
+ allowAdhoc?: boolean;
110
+ }
111
+
112
+ function summarizeSelection(
113
+ selection: TargetSelection,
114
+ ): ExecuteSelectionSummary {
115
+ return {
116
+ groups: selection.groups,
117
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
118
+ all: selection.all,
119
+ default: selection.default,
120
+ positionals: selection.positionals,
121
+ quickchr: selection.quickchr,
122
+ };
123
+ }
124
+
125
+ /** Representative transport for the concurrency default (pinned > native member > global). */
126
+ function fanoutVia(
127
+ request: ExecuteRequest,
128
+ env: Record<string, string | undefined>,
129
+ globalVia: RouterOsProtocol,
130
+ expansion: SelectionExpansion,
131
+ ): RouterOsProtocol {
132
+ // Deliberately CLI/env only, not `config` — see the identical note in
133
+ // `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
134
+ if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
135
+ return globalVia;
136
+ }
137
+ const anyNative = expansion.targets.some(
138
+ (member) =>
139
+ member.kind === "cdb" &&
140
+ member.resolution.overrides.via?.value === "native-api",
141
+ );
142
+ return anyNative ? "native-api" : globalVia;
143
+ }
144
+
145
+ function memberTargetMeta(member: SelectionMember): {
146
+ input?: string;
147
+ identity?: string;
148
+ recordIndex?: number;
149
+ } {
150
+ if (member.kind === "cdb") {
151
+ return {
152
+ input: member.resolution.identity,
153
+ identity: member.resolution.identity,
154
+ recordIndex: member.recordIndex,
155
+ };
156
+ }
157
+ if (member.kind === "quickchr") {
158
+ return { input: member.name, identity: member.name };
159
+ }
160
+ return { input: member.input };
161
+ }
162
+
163
+ /**
164
+ * Ad-hoc literal targets have no CDB record. `resolveExecuteRequest` may borrow the
165
+ * `__default__` record's index when filling fallback creds, so the inner envelope
166
+ * would carry that borrowed `recordIndex` — making distinct literals collide on one
167
+ * index. Drop it for literal members and stamp the caller's `input` so the target
168
+ * stays identifiable in renderers (mirrors `api-fanout.ts`).
169
+ */
170
+ function relabelAdhocMember(
171
+ envelope: ExecuteEnvelope,
172
+ member: SelectionMember,
173
+ ): ExecuteEnvelope {
174
+ if (member.kind !== "literal") {
175
+ return envelope;
176
+ }
177
+ const { recordIndex: _borrowed, ...target } = envelope.meta.target;
178
+ return {
179
+ ...envelope,
180
+ meta: { ...envelope.meta, target: { ...target, input: member.input } },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Run an `execute` fan-out. Returns a success envelope when the orchestration
186
+ * completes (even if every target failed); throws only for pre-flight failures
187
+ * (invalid command shape, CDB decrypt, unconfirmed write) the caller renders as
188
+ * an outer error via {@link buildExecuteFanoutErrorEnvelope}.
189
+ */
190
+ export async function executeFanout(
191
+ request: ExecuteRequest,
192
+ selection: TargetSelection,
193
+ env: Record<string, string | undefined> = Bun.env,
194
+ internals: ExecuteFanoutInternals = {},
195
+ options: ExecuteFanoutOptions = {},
196
+ ): Promise<ExecuteFanoutEnvelope> {
197
+ // Validate + canonicalize the command ONCE, before selection expansion: a bad
198
+ // command shape is an outer error regardless of how many targets resolve, and
199
+ // write-ness is target-independent.
200
+ const config = await loadEnvFileDefaults(env);
201
+ const global = resolveExecuteGlobalContext(request, env, config);
202
+ const selectionSummary = summarizeSelection(selection);
203
+
204
+ const expand = internals.expand ?? expandSelection;
205
+ const expansion = await expand(
206
+ selection,
207
+ {
208
+ cdbFile: request.cdbFile,
209
+ cdbPassword: request.cdbPassword,
210
+ allowAdhoc: options.allowAdhoc ?? true,
211
+ },
212
+ env,
213
+ config,
214
+ );
215
+
216
+ const via = fanoutVia(request, env, global.via, expansion);
217
+ const concurrency = resolveFanoutConcurrency(options.concurrency, via);
218
+
219
+ if (expansion.empty) {
220
+ return executeFanoutEnvelope({
221
+ selection: selectionSummary,
222
+ concurrency,
223
+ summary: { total: 0, ok: 0, failed: 0 },
224
+ requestSummary: global.summary,
225
+ settings: global.settings,
226
+ via: null,
227
+ warnings: expansion.warnings,
228
+ targets: [],
229
+ });
230
+ }
231
+
232
+ // Write-shaped fan-out: confirm ONCE up front, naming the blast radius.
233
+ await assertFanoutWriteConfirmed(request, global.summary, expansion);
234
+
235
+ const execute = internals.execute ?? runResolvedExecute;
236
+ const sleep = internals.sleep ?? defaultFanoutSleep;
237
+
238
+ const targets = await runFanout<
239
+ SelectionMember,
240
+ ResolvedExecuteRequest,
241
+ ExecuteEnvelope
242
+ >({
243
+ members: expansion.targets,
244
+ concurrency,
245
+ resolve: (member) => {
246
+ if (member.kind === "cdb") {
247
+ if (isDefaultRecordTarget(member.resolution.target)) {
248
+ throw new CentrsError({
249
+ code: "target/unresolved",
250
+ summary:
251
+ "The `__default__` record is a credential fallback, not a connectable router.",
252
+ remediation:
253
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
254
+ context: { target: member.resolution.target },
255
+ });
256
+ }
257
+ return resolveExecuteRequest(
258
+ { ...request, targetInput: member.resolution.target },
259
+ env,
260
+ { cdbResolution: member.resolution, config },
261
+ );
262
+ }
263
+ if (member.kind === "quickchr") {
264
+ // The live descriptor resolves here, per member — a stopped/unknown
265
+ // machine is an inner per-target failure, not an expansion error.
266
+ return resolveExecuteRequest(
267
+ { ...request, targetInput: undefined, quickchr: member.name },
268
+ env,
269
+ { config },
270
+ );
271
+ }
272
+ return resolveExecuteRequest(
273
+ { ...request, targetInput: member.input },
274
+ env,
275
+ { config },
276
+ );
277
+ },
278
+ onResolveError: (member, error) =>
279
+ buildFanoutResolveFailure<ExecuteOperationMeta>({
280
+ error,
281
+ target: memberTargetMeta(member),
282
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
283
+ summary: "Failed to resolve a fanout target.",
284
+ }),
285
+ execute: async (resolved, member) =>
286
+ relabelAdhocMember(await execute(resolved), member),
287
+ onExecuteError: (resolved, member, error) =>
288
+ relabelAdhocMember(
289
+ buildExecuteErrorEnvelopeFromResolved(resolved, error),
290
+ member,
291
+ ),
292
+ sleep,
293
+ });
294
+
295
+ return executeFanoutEnvelope({
296
+ selection: selectionSummary,
297
+ concurrency,
298
+ summary: summarizeFanout(targets),
299
+ requestSummary: global.summary,
300
+ settings: global.settings,
301
+ via: commonVia(targets),
302
+ warnings: expansion.warnings,
303
+ targets,
304
+ });
305
+ }
306
+
307
+ async function assertFanoutWriteConfirmed(
308
+ request: ExecuteRequest,
309
+ summary: ExecuteRequestSummary,
310
+ expansion: SelectionExpansion,
311
+ ): Promise<void> {
312
+ if (!summary.write || request.yes) {
313
+ return;
314
+ }
315
+ const count = expansion.targets.length;
316
+ const prompt = `Run write-shaped \`${summary.command}\` across ${count} router(s)? Type yes to continue: `;
317
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
318
+ const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
319
+ prompt,
320
+ );
321
+ if (confirmed) {
322
+ return;
323
+ }
324
+ }
325
+ throw new CentrsError({
326
+ code: "usage/confirmation-required",
327
+ summary: `A write-shaped \`${summary.command}\` would run across ${count} router(s) and needs explicit confirmation.`,
328
+ remediation:
329
+ "Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
330
+ context: { command: summary.command, routers: count },
331
+ });
332
+ }
333
+
334
+ interface ExecuteFanoutEnvelopeInput {
335
+ selection: ExecuteSelectionSummary;
336
+ concurrency: number;
337
+ summary: FanoutSummary;
338
+ requestSummary: ExecuteRequestSummary;
339
+ settings: CommonSettingsMeta;
340
+ via: RouterOsProtocol | null;
341
+ warnings: SelectionExpansion["warnings"];
342
+ targets: readonly ExecuteEnvelope[];
343
+ }
344
+
345
+ function executeFanoutEnvelope(
346
+ input: ExecuteFanoutEnvelopeInput,
347
+ ): ExecuteFanoutEnvelope {
348
+ return buildFanoutEnvelope<
349
+ unknown,
350
+ ExecuteOperationMeta,
351
+ ExecuteFanoutOperationMeta
352
+ >({
353
+ summary: input.summary,
354
+ targets: input.targets,
355
+ warnings: input.warnings,
356
+ settings: input.settings,
357
+ via: input.via,
358
+ operation: {
359
+ kind: "fanout",
360
+ concurrency: input.concurrency,
361
+ summary: input.summary,
362
+ request: input.requestSummary,
363
+ selection: input.selection,
364
+ },
365
+ });
366
+ }
367
+
368
+ /** Outer error envelope for a fan-out that failed before per-target results. */
369
+ export function buildExecuteFanoutErrorEnvelope(
370
+ request: ExecuteRequest,
371
+ error: unknown,
372
+ ): ExecuteFanoutErrorEnvelope {
373
+ const centrsError =
374
+ error instanceof CentrsError
375
+ ? error
376
+ : new CentrsError({
377
+ code: "internal/unhandled",
378
+ summary: "execute fan-out failed with an unexpected error.",
379
+ remediation:
380
+ "Re-run with `--format json` to capture the structured error details.",
381
+ cause: error,
382
+ });
383
+ const requestedVia = plannedProtocols.includes(
384
+ request.via as RouterOsProtocol,
385
+ )
386
+ ? (request.via as RouterOsProtocol)
387
+ : null;
388
+ return {
389
+ ok: false,
390
+ error: serializeCentrsError(centrsError),
391
+ warnings: [],
392
+ tips: [],
393
+ meta: {
394
+ target: {},
395
+ via: requestedVia,
396
+ settings: {},
397
+ },
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Render an execute fan-out envelope. `json`/`yaml` serialize the whole envelope;
403
+ * `text` renders a summary line plus one line per target in record-index order.
404
+ */
405
+ export function renderExecuteFanoutEnvelope(
406
+ envelope: ExecuteFanoutEnvelope | ExecuteFanoutErrorEnvelope,
407
+ format: "json" | "yaml" | "text",
408
+ options: { verbose?: boolean } = {},
409
+ ): string {
410
+ if (format === "json") {
411
+ return JSON.stringify(envelope, null, 2);
412
+ }
413
+ if (format === "yaml") {
414
+ return toYaml(envelope);
415
+ }
416
+ if (!envelope.ok) {
417
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
418
+ if (envelope.error.remediation) {
419
+ lines.push(`Fix: ${envelope.error.remediation}`);
420
+ }
421
+ return lines.join("\n");
422
+ }
423
+ const operation = envelope.meta.operation;
424
+ const { total, ok, failed } = envelope.data.summary;
425
+ const lines: string[] = [
426
+ `execute ${operation?.request.command ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
427
+ ];
428
+ for (const target of envelope.data.targets) {
429
+ const meta = target.meta;
430
+ const label =
431
+ meta.target.identity ??
432
+ meta.target.host ??
433
+ meta.target.input ??
434
+ "(unknown)";
435
+ const index = meta.target.recordIndex ?? "-";
436
+ if (target.ok) {
437
+ lines.push(
438
+ ` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
439
+ );
440
+ } else {
441
+ lines.push(
442
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
443
+ );
444
+ if (options.verbose && target.error.remediation) {
445
+ lines.push(` Fix: ${target.error.remediation}`);
446
+ }
447
+ }
448
+ }
449
+ for (const warning of envelope.warnings) {
450
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
451
+ }
452
+ return lines.join("\n");
453
+ }