@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
@@ -0,0 +1,25 @@
1
+ # `settings/unknown-key`
2
+
3
+ `settings get`/`set`/`reset` received a token that is not a recognized CENTRS_*-shaped setting.
4
+
5
+ ## Fix
6
+
7
+ `centrs settings get <attr>`, `set <attr> <value>`, and `reset <attr>` all
8
+ normalize the token first — with or without the `CENTRS_` prefix, in
9
+ kebab-case or SCREAMING_SNAKE (`format`, `FORMAT`, `centrs_format`, and
10
+ `CENTRS_FORMAT` all resolve to the same key) — then look it up against the 13
11
+ keys `settings` manages plus the 5 refused keys. This error fires when the
12
+ normalized token matches neither list: a typo, a made-up name, or a
13
+ `CENTRS_*` var no shipped command actually reads (see
14
+ `commands/settings/README.md`, "Not yet wired — exclude from v1", for three
15
+ such names that are deliberately unsupported today).
16
+
17
+ Run `centrs settings print --all` to see every key `settings` recognizes,
18
+ plus any unrecognized `CENTRS_*` lines already present in the file, tagged
19
+ `(unrecognized)` — that second list is the fastest way to spot a typo you
20
+ already wrote into `centrs.env` by hand.
21
+
22
+ This is a different failure from [`settings/reserved-key`](reserved-key.md):
23
+ that error means "this is a real setting, but writing it is off-limits";
24
+ `settings/unknown-key` means the token isn't a setting `centrs` knows about
25
+ at all. See examples 9 and 19 in `commands/settings/examples.md`.
@@ -0,0 +1,16 @@
1
+ # `usage/fanout-not-supported`
2
+
3
+ The command or mode cannot fan out across multiple targets.
4
+
5
+ ## Fix
6
+
7
+ Some surfaces are single-session by nature and reject a multi-target selection:
8
+
9
+ - `terminal` — an interactive console relay to one device.
10
+ - `api --stream` / `api --listen` — an open-ended native-api follow is a single
11
+ long-lived subscription.
12
+
13
+ Run the command against a single target (one positional, no `--group` / `--where` /
14
+ `--all` / `--default`). To act on a fleet, use a fan-out-capable verb instead
15
+ (`retrieve`, `execute`, `api` without `--stream`, `transfer`). See
16
+ [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) (Target selection).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -11,15 +11,14 @@
11
11
  "bugs": "https://github.com/tikoci/centrs/issues",
12
12
  "packageManager": "bun@1.3.13",
13
13
  "devDependencies": {
14
- "@agent-lint/cli": "^0.8.1",
15
- "@biomejs/biome": "2.5.1",
14
+ "@biomejs/biome": "2.5.2",
16
15
  "@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
17
16
  "@tikoci/quickchr": "^0.4.2",
18
17
  "@types/bun": "^1.3.13",
19
18
  "cloc": "^2.6.0-cloc",
20
19
  "cspell": "^10.0.0",
21
20
  "has-flag": "4.0.0",
22
- "markdownlint-cli2": "^0.22.1",
21
+ "markdownlint-cli2": "^0.23.0",
23
22
  "secretlint": "^13.0.2",
24
23
  "typedoc": "^0.28.19"
25
24
  },
@@ -70,10 +69,10 @@
70
69
  "lint:biome": "biome check .",
71
70
  "lint:fix": "biome check --write .",
72
71
  "lint:secretlint": "secretlint \"**/*\"",
73
- "lint:agents": "agent-lint scan --stdout",
74
72
  "test": "bun test",
75
73
  "test:smoke": "bun test test/integration/cli-smoke.test.ts",
76
- "test:integration:fast": "bun test test/integration/devices.test.ts",
74
+ "test:integration:fast": "bun test test/integration/devices.test.ts test/integration/settings.test.ts",
75
+ "test:integration:settings": "bun test test/integration/settings.test.ts",
77
76
  "test:integration:smoke": "CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/chr-smoke.test.ts",
78
77
  "test:integration": "CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/",
79
78
  "test:integration:stable": "CENTRS_CHR_CHANNEL=stable bun run test:integration",
@@ -82,6 +81,7 @@
82
81
  "test:integration:development": "CENTRS_CHR_CHANNEL=development bun run test:integration",
83
82
  "test:ci": "bun test --coverage",
84
83
  "test:all": "bun run test:ci",
84
+ "check:agent": "bun run scripts/agent-check.ts",
85
85
  "build": "bun build src/index.ts src/cli.ts src/mcp.ts src/tui.ts src/webproxy.ts src/protocols/index.ts --outdir dist --target bun",
86
86
  "build:doc:api": "typedoc --out docs/api src/index.ts",
87
87
  "docs:errors": "bun run scripts/gen-error-pages.ts",
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Multi-target fan-out for `api`.
3
+ *
4
+ * A target selection (`--group` / `--where` / `--all` / `--default` / multiple
5
+ * positionals) expands to N members and runs each through the single-target api
6
+ * tail (`resolveApiRequest` → `runResolvedApi`), collecting per-target inner
7
+ * `ApiEnvelope`s into the locked `FanoutData` shape (`src/core/envelope.ts`).
8
+ *
9
+ * Boundaries (see `commands/api/README.md`, `docs/CONSTITUTION.md`):
10
+ * - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved
11
+ * with its pre-resolved record, so `resolveCdb`'s `__default__` synthetic
12
+ * fallback never collides ad-hoc literals.
13
+ * - A `--default` / `__default__` member is guarded from being dialed as the
14
+ * literal hostname `"__default__"` — it fails that one target deterministically
15
+ * with `target/unresolved`.
16
+ * - A mutating fan-out is confirmed ONCE up front (`--yes`), never per target;
17
+ * without `--yes` the error names the blast radius (how many routers).
18
+ * - `--listen`/`--stream` and `--raw` are rejected in fan-out mode by the CLI
19
+ * before reaching here (single-session / envelope-stripping).
20
+ */
21
+
22
+ import {
23
+ type ApiEnvelope,
24
+ type ApiOperationMeta,
25
+ type ApiRequest,
26
+ type ApiRequestSummary,
27
+ type ApiSuccessEnvelope,
28
+ apiRequestSummaryFromRequest,
29
+ assertApiProtocolSupported,
30
+ buildApiErrorEnvelopeFromResolved,
31
+ parseApiMethod,
32
+ type ResolvedApiRequest,
33
+ resolveApiRequest,
34
+ runResolvedApi,
35
+ validateApiRequestShape,
36
+ } from "./api.ts";
37
+ import type {
38
+ CentrsErrorEnvelope,
39
+ CentrsSuccessEnvelope,
40
+ FanoutData,
41
+ FanoutSummary,
42
+ } from "./core/envelope.ts";
43
+ import {
44
+ buildFanoutEnvelope,
45
+ buildFanoutResolveFailure,
46
+ commonVia,
47
+ defaultFanoutSleep,
48
+ resolveFanoutConcurrency,
49
+ runFanout,
50
+ summarizeFanout,
51
+ } from "./core/fanout.ts";
52
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
53
+ import { promptForWriteConfirmation } from "./execute.ts";
54
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
55
+ import {
56
+ type CdbSelectionExpansion,
57
+ type CdbSelectionMember,
58
+ type CdbSelectionResolveInput,
59
+ expandCdbSelection,
60
+ isDefaultRecordTarget,
61
+ loadEnvFileDefaults,
62
+ type TargetSelection,
63
+ } from "./resolver/index.ts";
64
+ import { toYaml } from "./retrieve.ts";
65
+
66
+ /** Compact description of the selector that produced the fan-out. */
67
+ export interface ApiSelectionSummary {
68
+ groups: readonly string[];
69
+ where: readonly string[];
70
+ all: boolean;
71
+ default: boolean;
72
+ positionals: readonly string[];
73
+ }
74
+
75
+ export interface ApiFanoutOperationMeta {
76
+ kind: "fanout";
77
+ concurrency: number;
78
+ summary: FanoutSummary;
79
+ request: ApiRequestSummary;
80
+ selection: ApiSelectionSummary;
81
+ }
82
+
83
+ export type ApiFanoutData = FanoutData<unknown, ApiOperationMeta>;
84
+ export type ApiFanoutEnvelope = CentrsSuccessEnvelope<
85
+ ApiFanoutData,
86
+ ApiFanoutOperationMeta
87
+ >;
88
+ export type ApiFanoutErrorEnvelope =
89
+ CentrsErrorEnvelope<ApiFanoutOperationMeta>;
90
+
91
+ /** Test/override seams; production callers pass nothing. */
92
+ export interface ApiFanoutInternals {
93
+ expand?: (
94
+ selection: TargetSelection,
95
+ input: CdbSelectionResolveInput,
96
+ env: Record<string, string | undefined>,
97
+ config?: Record<string, string | undefined>,
98
+ ) => Promise<CdbSelectionExpansion>;
99
+ execute?: (resolved: ResolvedApiRequest) => Promise<ApiSuccessEnvelope>;
100
+ sleep?: (ms: number) => Promise<void>;
101
+ }
102
+
103
+ export interface ApiFanoutOptions {
104
+ concurrency?: number;
105
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
106
+ allowAdhoc?: boolean;
107
+ }
108
+
109
+ function summarizeSelection(selection: TargetSelection): ApiSelectionSummary {
110
+ return {
111
+ groups: selection.groups,
112
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
113
+ all: selection.all,
114
+ default: selection.default,
115
+ positionals: selection.positionals,
116
+ };
117
+ }
118
+
119
+ /** Representative transport for the concurrency default (pinned > native member > rest). */
120
+ function fanoutVia(
121
+ request: ApiRequest,
122
+ env: Record<string, string | undefined>,
123
+ expansion: CdbSelectionExpansion,
124
+ ): RouterOsProtocol {
125
+ // Deliberately CLI/env only, not `config` — see the note above this
126
+ // function's caller.
127
+ const pinned = request.via ?? env["CENTRS_VIA"];
128
+ if (
129
+ pinned !== undefined &&
130
+ plannedProtocols.includes(pinned as RouterOsProtocol)
131
+ ) {
132
+ return pinned as RouterOsProtocol;
133
+ }
134
+ const anyNative = expansion.targets.some(
135
+ (member) =>
136
+ member.kind === "cdb" &&
137
+ member.resolution.overrides.via?.value === "native-api",
138
+ );
139
+ return anyNative ? "native-api" : "rest-api";
140
+ }
141
+
142
+ function memberTargetMeta(member: CdbSelectionMember): {
143
+ input?: string;
144
+ identity?: string;
145
+ recordIndex?: number;
146
+ } {
147
+ if (member.kind === "cdb") {
148
+ return {
149
+ input: member.resolution.identity,
150
+ identity: member.resolution.identity,
151
+ recordIndex: member.recordIndex,
152
+ };
153
+ }
154
+ return { input: member.input };
155
+ }
156
+
157
+ /**
158
+ * Ad-hoc literal targets have no CDB record. `resolveApiRequest` may borrow the
159
+ * `__default__` record's index when filling fallback creds, so the inner envelope
160
+ * would carry that borrowed `recordIndex` — making distinct literals collide on one
161
+ * index. Drop it for literal members and stamp the caller's `input` so the target
162
+ * stays identifiable in renderers (see {@link renderApiFanoutEnvelope}).
163
+ */
164
+ function relabelAdhocMember(
165
+ envelope: ApiEnvelope,
166
+ member: CdbSelectionMember,
167
+ ): ApiEnvelope {
168
+ if (member.kind !== "literal") {
169
+ return envelope;
170
+ }
171
+ const { recordIndex: _borrowed, ...target } = envelope.meta.target;
172
+ return {
173
+ ...envelope,
174
+ meta: { ...envelope.meta, target: { ...target, input: member.input } },
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Run an `api` fan-out. Returns a success envelope when the orchestration
180
+ * completes (even if every target failed); throws only for pre-flight failures
181
+ * (invalid method, CDB decrypt, unconfirmed write) the caller renders as an
182
+ * outer error via {@link buildApiFanoutErrorEnvelope}.
183
+ */
184
+ export async function apiFanout(
185
+ request: ApiRequest,
186
+ selection: TargetSelection,
187
+ env: Record<string, string | undefined> = Bun.env,
188
+ internals: ApiFanoutInternals = {},
189
+ options: ApiFanoutOptions = {},
190
+ ): Promise<ApiFanoutEnvelope> {
191
+ // Validate the request shape + method ONCE, before selection expansion: a
192
+ // missing endpoint or an invalid `-X` is an outer error regardless of how many
193
+ // targets the selection resolves to (so an empty selection cannot mask it).
194
+ validateApiRequestShape(request);
195
+ const config = await loadEnvFileDefaults(env);
196
+ const method = parseApiMethod(request.method);
197
+ const requestSummary = apiRequestSummaryFromRequest(request, env);
198
+ const selectionSummary = summarizeSelection(selection);
199
+
200
+ // A globally-pinned `--via` / `CENTRS_VIA` outranks every per-target CDB
201
+ // override (explicit/env > comment-kv in `resolveStringSetting`), so an invalid
202
+ // one fails every target identically. Reject it ONCE up front as an outer error,
203
+ // matching single-target `resolveApiProtocol`, instead of N per-target failures.
204
+ // Deliberately CLI/env only, not `config` — see the identical note in
205
+ // `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`: a `centrs.env`
206
+ // default is the weakest tier and must not be treated as a hard pin.
207
+ const pinnedVia = request.via ?? env["CENTRS_VIA"];
208
+ if (pinnedVia !== undefined) {
209
+ assertApiProtocolSupported(pinnedVia);
210
+ }
211
+
212
+ const expand = internals.expand ?? expandCdbSelection;
213
+ const expansion = await expand(
214
+ selection,
215
+ {
216
+ cdbFile: request.cdbFile,
217
+ cdbPassword: request.cdbPassword,
218
+ allowAdhoc: options.allowAdhoc ?? true,
219
+ },
220
+ env,
221
+ config,
222
+ );
223
+
224
+ const via = fanoutVia(request, env, expansion);
225
+ const concurrency = resolveFanoutConcurrency(options.concurrency, via);
226
+
227
+ if (expansion.empty) {
228
+ return buildApiFanoutSuccess({
229
+ summary: { total: 0, ok: 0, failed: 0 },
230
+ targets: [],
231
+ warnings: expansion.warnings,
232
+ via: null,
233
+ concurrency,
234
+ requestSummary,
235
+ selectionSummary,
236
+ });
237
+ }
238
+
239
+ // Mutating fan-out: confirm ONCE up front, naming the blast radius.
240
+ await assertFanoutWriteConfirmed(request, requestSummary, method, expansion);
241
+
242
+ const execute = internals.execute ?? runResolvedApi;
243
+ const sleep = internals.sleep ?? defaultFanoutSleep;
244
+
245
+ const targets = await runFanout<
246
+ CdbSelectionMember,
247
+ ResolvedApiRequest,
248
+ ApiEnvelope
249
+ >({
250
+ members: expansion.targets,
251
+ concurrency,
252
+ resolve: (member) => {
253
+ if (member.kind === "cdb") {
254
+ if (isDefaultRecordTarget(member.resolution.target)) {
255
+ throw new CentrsError({
256
+ code: "target/unresolved",
257
+ summary:
258
+ "The `__default__` record is a credential fallback, not a connectable router.",
259
+ remediation:
260
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
261
+ context: { target: member.resolution.target },
262
+ });
263
+ }
264
+ return resolveApiRequest(
265
+ { ...request, targetInput: member.resolution.target },
266
+ env,
267
+ { cdbResolution: member.resolution, config },
268
+ );
269
+ }
270
+ return resolveApiRequest({ ...request, targetInput: member.input }, env, {
271
+ config,
272
+ });
273
+ },
274
+ onResolveError: (member, error) =>
275
+ buildFanoutResolveFailure<ApiOperationMeta>({
276
+ error,
277
+ target: memberTargetMeta(member),
278
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
279
+ }),
280
+ execute: async (resolved, member) =>
281
+ relabelAdhocMember(await execute(resolved), member),
282
+ onExecuteError: (resolved, member, error) =>
283
+ relabelAdhocMember(
284
+ buildApiErrorEnvelopeFromResolved(resolved, error),
285
+ member,
286
+ ),
287
+ sleep,
288
+ });
289
+
290
+ return buildApiFanoutSuccess({
291
+ summary: summarizeFanout(targets),
292
+ targets,
293
+ warnings: expansion.warnings,
294
+ via: commonVia(targets),
295
+ concurrency,
296
+ requestSummary,
297
+ selectionSummary,
298
+ });
299
+ }
300
+
301
+ async function assertFanoutWriteConfirmed(
302
+ request: ApiRequest,
303
+ summary: ApiRequestSummary,
304
+ method: string,
305
+ expansion: CdbSelectionExpansion,
306
+ ): Promise<void> {
307
+ if (!summary.write || request.yes) {
308
+ return;
309
+ }
310
+ const count = expansion.targets.length;
311
+ const prompt = `Run mutating ${method} ${summary.path} across ${count} router(s)? Type yes to continue: `;
312
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
313
+ const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
314
+ prompt,
315
+ );
316
+ if (confirmed) {
317
+ return;
318
+ }
319
+ }
320
+ throw new CentrsError({
321
+ code: "usage/confirmation-required",
322
+ summary: `A mutating ${method} ${summary.path} would run across ${count} router(s) and needs explicit confirmation.`,
323
+ remediation:
324
+ "Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
325
+ context: { method, path: summary.path, routers: count },
326
+ });
327
+ }
328
+
329
+ interface ApiFanoutSuccessInput {
330
+ summary: FanoutSummary;
331
+ targets: readonly ApiEnvelope[];
332
+ warnings: CdbSelectionExpansion["warnings"];
333
+ via: RouterOsProtocol | null;
334
+ concurrency: number;
335
+ requestSummary: ApiRequestSummary;
336
+ selectionSummary: ApiSelectionSummary;
337
+ }
338
+
339
+ function buildApiFanoutSuccess(
340
+ input: ApiFanoutSuccessInput,
341
+ ): ApiFanoutEnvelope {
342
+ return buildFanoutEnvelope<unknown, ApiOperationMeta, ApiFanoutOperationMeta>(
343
+ {
344
+ summary: input.summary,
345
+ targets: input.targets,
346
+ warnings: input.warnings,
347
+ settings: {},
348
+ via: input.via,
349
+ operation: {
350
+ kind: "fanout",
351
+ concurrency: input.concurrency,
352
+ summary: input.summary,
353
+ request: input.requestSummary,
354
+ selection: input.selectionSummary,
355
+ },
356
+ },
357
+ );
358
+ }
359
+
360
+ /** Outer error envelope for a fan-out that failed before per-target results. */
361
+ export function buildApiFanoutErrorEnvelope(
362
+ request: ApiRequest,
363
+ selection: TargetSelection,
364
+ error: unknown,
365
+ env: Record<string, string | undefined> = Bun.env,
366
+ ): ApiFanoutErrorEnvelope {
367
+ const centrsError =
368
+ error instanceof CentrsError
369
+ ? error
370
+ : new CentrsError({
371
+ code: "internal/unhandled",
372
+ summary: "api fan-out failed with an unexpected error.",
373
+ remediation:
374
+ "Re-run with `--format json` to capture the structured error details.",
375
+ cause: error,
376
+ });
377
+ const requestedVia = plannedProtocols.includes(
378
+ request.via as RouterOsProtocol,
379
+ )
380
+ ? (request.via as RouterOsProtocol)
381
+ : null;
382
+ return {
383
+ ok: false,
384
+ error: serializeCentrsError(centrsError),
385
+ warnings: [],
386
+ tips: [],
387
+ meta: {
388
+ target: {},
389
+ via: requestedVia,
390
+ settings: {},
391
+ operation: {
392
+ kind: "fanout",
393
+ concurrency: 0,
394
+ summary: { total: 0, ok: 0, failed: 0 },
395
+ request: apiRequestSummaryFromRequest(request, env),
396
+ selection: summarizeSelection(selection),
397
+ },
398
+ },
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Render an api fan-out envelope. `json`/`yaml` serialize the whole envelope;
404
+ * `text` renders a summary line plus one line per target in record-index order.
405
+ */
406
+ export function renderApiFanoutEnvelope(
407
+ envelope: ApiFanoutEnvelope | ApiFanoutErrorEnvelope,
408
+ format: "json" | "yaml" | "text",
409
+ options: { verbose?: boolean } = {},
410
+ ): string {
411
+ if (format === "json") {
412
+ return JSON.stringify(envelope, null, 2);
413
+ }
414
+ if (format === "yaml") {
415
+ return toYaml(envelope);
416
+ }
417
+ if (!envelope.ok) {
418
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
419
+ if (envelope.error.remediation) {
420
+ lines.push(`Fix: ${envelope.error.remediation}`);
421
+ }
422
+ return lines.join("\n");
423
+ }
424
+ const operation = envelope.meta.operation;
425
+ const { total, ok, failed } = envelope.data.summary;
426
+ const lines: string[] = [
427
+ `api ${operation?.request.method} ${operation?.request.path}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
428
+ ];
429
+ for (const target of envelope.data.targets) {
430
+ const meta = target.meta;
431
+ const label =
432
+ meta.target.identity ??
433
+ meta.target.host ??
434
+ meta.target.input ??
435
+ "(unknown)";
436
+ const index = meta.target.recordIndex ?? "-";
437
+ if (target.ok) {
438
+ lines.push(
439
+ ` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
440
+ );
441
+ } else {
442
+ lines.push(
443
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
444
+ );
445
+ if (options.verbose && target.error.remediation) {
446
+ lines.push(` Fix: ${target.error.remediation}`);
447
+ }
448
+ }
449
+ }
450
+ for (const warning of envelope.warnings) {
451
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
452
+ }
453
+ return lines.join("\n");
454
+ }