@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,438 @@
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 (`expandCdbSelection`); 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
+ type CdbSelectionExpansion,
56
+ type CdbSelectionMember,
57
+ type CdbSelectionResolveInput,
58
+ expandCdbSelection,
59
+ isDefaultRecordTarget,
60
+ loadEnvFileDefaults,
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
+ }
73
+
74
+ export interface ExecuteFanoutOperationMeta {
75
+ kind: "fanout";
76
+ concurrency: number;
77
+ summary: FanoutSummary;
78
+ request: ExecuteRequestSummary;
79
+ selection: ExecuteSelectionSummary;
80
+ }
81
+
82
+ export type ExecuteFanoutData = FanoutData<unknown, ExecuteOperationMeta>;
83
+ export type ExecuteFanoutEnvelope = CentrsSuccessEnvelope<
84
+ ExecuteFanoutData,
85
+ ExecuteFanoutOperationMeta
86
+ >;
87
+ export type ExecuteFanoutErrorEnvelope =
88
+ CentrsErrorEnvelope<ExecuteFanoutOperationMeta>;
89
+
90
+ /** Test/override seams; production callers pass nothing. */
91
+ export interface ExecuteFanoutInternals {
92
+ expand?: (
93
+ selection: TargetSelection,
94
+ input: CdbSelectionResolveInput,
95
+ env: Record<string, string | undefined>,
96
+ config?: Record<string, string | undefined>,
97
+ ) => Promise<CdbSelectionExpansion>;
98
+ execute?: (
99
+ resolved: ResolvedExecuteRequest,
100
+ ) => Promise<ExecuteSuccessEnvelope>;
101
+ sleep?: (ms: number) => Promise<void>;
102
+ }
103
+
104
+ export interface ExecuteFanoutOptions {
105
+ concurrency?: number;
106
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
107
+ allowAdhoc?: boolean;
108
+ }
109
+
110
+ function summarizeSelection(
111
+ selection: TargetSelection,
112
+ ): ExecuteSelectionSummary {
113
+ return {
114
+ groups: selection.groups,
115
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
116
+ all: selection.all,
117
+ default: selection.default,
118
+ positionals: selection.positionals,
119
+ };
120
+ }
121
+
122
+ /** Representative transport for the concurrency default (pinned > native member > global). */
123
+ function fanoutVia(
124
+ request: ExecuteRequest,
125
+ env: Record<string, string | undefined>,
126
+ globalVia: RouterOsProtocol,
127
+ expansion: CdbSelectionExpansion,
128
+ ): RouterOsProtocol {
129
+ // Deliberately CLI/env only, not `config` — see the identical note in
130
+ // `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
131
+ if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
132
+ return globalVia;
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" : globalVia;
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. `resolveExecuteRequest` 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 (mirrors `api-fanout.ts`).
163
+ */
164
+ function relabelAdhocMember(
165
+ envelope: ExecuteEnvelope,
166
+ member: CdbSelectionMember,
167
+ ): ExecuteEnvelope {
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 `execute` fan-out. Returns a success envelope when the orchestration
180
+ * completes (even if every target failed); throws only for pre-flight failures
181
+ * (invalid command shape, CDB decrypt, unconfirmed write) the caller renders as
182
+ * an outer error via {@link buildExecuteFanoutErrorEnvelope}.
183
+ */
184
+ export async function executeFanout(
185
+ request: ExecuteRequest,
186
+ selection: TargetSelection,
187
+ env: Record<string, string | undefined> = Bun.env,
188
+ internals: ExecuteFanoutInternals = {},
189
+ options: ExecuteFanoutOptions = {},
190
+ ): Promise<ExecuteFanoutEnvelope> {
191
+ // Validate + canonicalize the command ONCE, before selection expansion: a bad
192
+ // command shape is an outer error regardless of how many targets resolve, and
193
+ // write-ness is target-independent.
194
+ const config = await loadEnvFileDefaults(env);
195
+ const global = resolveExecuteGlobalContext(request, env, config);
196
+ const selectionSummary = summarizeSelection(selection);
197
+
198
+ const expand = internals.expand ?? expandCdbSelection;
199
+ const expansion = await expand(
200
+ selection,
201
+ {
202
+ cdbFile: request.cdbFile,
203
+ cdbPassword: request.cdbPassword,
204
+ allowAdhoc: options.allowAdhoc ?? true,
205
+ },
206
+ env,
207
+ config,
208
+ );
209
+
210
+ const via = fanoutVia(request, env, global.via, expansion);
211
+ const concurrency = resolveFanoutConcurrency(options.concurrency, via);
212
+
213
+ if (expansion.empty) {
214
+ return executeFanoutEnvelope({
215
+ selection: selectionSummary,
216
+ concurrency,
217
+ summary: { total: 0, ok: 0, failed: 0 },
218
+ requestSummary: global.summary,
219
+ settings: global.settings,
220
+ via: null,
221
+ warnings: expansion.warnings,
222
+ targets: [],
223
+ });
224
+ }
225
+
226
+ // Write-shaped fan-out: confirm ONCE up front, naming the blast radius.
227
+ await assertFanoutWriteConfirmed(request, global.summary, expansion);
228
+
229
+ const execute = internals.execute ?? runResolvedExecute;
230
+ const sleep = internals.sleep ?? defaultFanoutSleep;
231
+
232
+ const targets = await runFanout<
233
+ CdbSelectionMember,
234
+ ResolvedExecuteRequest,
235
+ ExecuteEnvelope
236
+ >({
237
+ members: expansion.targets,
238
+ concurrency,
239
+ resolve: (member) => {
240
+ if (member.kind === "cdb") {
241
+ if (isDefaultRecordTarget(member.resolution.target)) {
242
+ throw new CentrsError({
243
+ code: "target/unresolved",
244
+ summary:
245
+ "The `__default__` record is a credential fallback, not a connectable router.",
246
+ remediation:
247
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
248
+ context: { target: member.resolution.target },
249
+ });
250
+ }
251
+ return resolveExecuteRequest(
252
+ { ...request, targetInput: member.resolution.target },
253
+ env,
254
+ { cdbResolution: member.resolution, config },
255
+ );
256
+ }
257
+ return resolveExecuteRequest(
258
+ { ...request, targetInput: member.input },
259
+ env,
260
+ { config },
261
+ );
262
+ },
263
+ onResolveError: (member, error) =>
264
+ buildFanoutResolveFailure<ExecuteOperationMeta>({
265
+ error,
266
+ target: memberTargetMeta(member),
267
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
268
+ summary: "Failed to resolve a fanout target.",
269
+ }),
270
+ execute: async (resolved, member) =>
271
+ relabelAdhocMember(await execute(resolved), member),
272
+ onExecuteError: (resolved, member, error) =>
273
+ relabelAdhocMember(
274
+ buildExecuteErrorEnvelopeFromResolved(resolved, error),
275
+ member,
276
+ ),
277
+ sleep,
278
+ });
279
+
280
+ return executeFanoutEnvelope({
281
+ selection: selectionSummary,
282
+ concurrency,
283
+ summary: summarizeFanout(targets),
284
+ requestSummary: global.summary,
285
+ settings: global.settings,
286
+ via: commonVia(targets),
287
+ warnings: expansion.warnings,
288
+ targets,
289
+ });
290
+ }
291
+
292
+ async function assertFanoutWriteConfirmed(
293
+ request: ExecuteRequest,
294
+ summary: ExecuteRequestSummary,
295
+ expansion: CdbSelectionExpansion,
296
+ ): Promise<void> {
297
+ if (!summary.write || request.yes) {
298
+ return;
299
+ }
300
+ const count = expansion.targets.length;
301
+ const prompt = `Run write-shaped \`${summary.command}\` across ${count} router(s)? Type yes to continue: `;
302
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
303
+ const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
304
+ prompt,
305
+ );
306
+ if (confirmed) {
307
+ return;
308
+ }
309
+ }
310
+ throw new CentrsError({
311
+ code: "usage/confirmation-required",
312
+ summary: `A write-shaped \`${summary.command}\` would run across ${count} router(s) and needs explicit confirmation.`,
313
+ remediation:
314
+ "Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
315
+ context: { command: summary.command, routers: count },
316
+ });
317
+ }
318
+
319
+ interface ExecuteFanoutEnvelopeInput {
320
+ selection: ExecuteSelectionSummary;
321
+ concurrency: number;
322
+ summary: FanoutSummary;
323
+ requestSummary: ExecuteRequestSummary;
324
+ settings: CommonSettingsMeta;
325
+ via: RouterOsProtocol | null;
326
+ warnings: CdbSelectionExpansion["warnings"];
327
+ targets: readonly ExecuteEnvelope[];
328
+ }
329
+
330
+ function executeFanoutEnvelope(
331
+ input: ExecuteFanoutEnvelopeInput,
332
+ ): ExecuteFanoutEnvelope {
333
+ return buildFanoutEnvelope<
334
+ unknown,
335
+ ExecuteOperationMeta,
336
+ ExecuteFanoutOperationMeta
337
+ >({
338
+ summary: input.summary,
339
+ targets: input.targets,
340
+ warnings: input.warnings,
341
+ settings: input.settings,
342
+ via: input.via,
343
+ operation: {
344
+ kind: "fanout",
345
+ concurrency: input.concurrency,
346
+ summary: input.summary,
347
+ request: input.requestSummary,
348
+ selection: input.selection,
349
+ },
350
+ });
351
+ }
352
+
353
+ /** Outer error envelope for a fan-out that failed before per-target results. */
354
+ export function buildExecuteFanoutErrorEnvelope(
355
+ request: ExecuteRequest,
356
+ error: unknown,
357
+ ): ExecuteFanoutErrorEnvelope {
358
+ const centrsError =
359
+ error instanceof CentrsError
360
+ ? error
361
+ : new CentrsError({
362
+ code: "internal/unhandled",
363
+ summary: "execute fan-out failed with an unexpected error.",
364
+ remediation:
365
+ "Re-run with `--format json` to capture the structured error details.",
366
+ cause: error,
367
+ });
368
+ const requestedVia = plannedProtocols.includes(
369
+ request.via as RouterOsProtocol,
370
+ )
371
+ ? (request.via as RouterOsProtocol)
372
+ : null;
373
+ return {
374
+ ok: false,
375
+ error: serializeCentrsError(centrsError),
376
+ warnings: [],
377
+ tips: [],
378
+ meta: {
379
+ target: {},
380
+ via: requestedVia,
381
+ settings: {},
382
+ },
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Render an execute fan-out envelope. `json`/`yaml` serialize the whole envelope;
388
+ * `text` renders a summary line plus one line per target in record-index order.
389
+ */
390
+ export function renderExecuteFanoutEnvelope(
391
+ envelope: ExecuteFanoutEnvelope | ExecuteFanoutErrorEnvelope,
392
+ format: "json" | "yaml" | "text",
393
+ options: { verbose?: boolean } = {},
394
+ ): string {
395
+ if (format === "json") {
396
+ return JSON.stringify(envelope, null, 2);
397
+ }
398
+ if (format === "yaml") {
399
+ return toYaml(envelope);
400
+ }
401
+ if (!envelope.ok) {
402
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
403
+ if (envelope.error.remediation) {
404
+ lines.push(`Fix: ${envelope.error.remediation}`);
405
+ }
406
+ return lines.join("\n");
407
+ }
408
+ const operation = envelope.meta.operation;
409
+ const { total, ok, failed } = envelope.data.summary;
410
+ const lines: string[] = [
411
+ `execute ${operation?.request.command ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
412
+ ];
413
+ for (const target of envelope.data.targets) {
414
+ const meta = target.meta;
415
+ const label =
416
+ meta.target.identity ??
417
+ meta.target.host ??
418
+ meta.target.input ??
419
+ "(unknown)";
420
+ const index = meta.target.recordIndex ?? "-";
421
+ if (target.ok) {
422
+ lines.push(
423
+ ` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
424
+ );
425
+ } else {
426
+ lines.push(
427
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
428
+ );
429
+ if (options.verbose && target.error.remediation) {
430
+ lines.push(` Fix: ${target.error.remediation}`);
431
+ }
432
+ }
433
+ }
434
+ for (const warning of envelope.warnings) {
435
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
436
+ }
437
+ return lines.join("\n");
438
+ }
package/src/execute.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  effectiveHostCandidate,
36
36
  isIpTransport,
37
37
  isMacAddress,
38
+ loadEnvFileDefaults,
38
39
  parseDuration,
39
40
  parseResolvePolicy,
40
41
  type ResolvedAuth,
@@ -277,19 +278,29 @@ export async function runResolvedExecute(
277
278
  export async function resolveExecuteRequest(
278
279
  request: ExecuteRequest,
279
280
  env: Record<string, string | undefined>,
281
+ options: {
282
+ cdbResolution?: CdbResolution;
283
+ config?: Record<string, string | undefined>;
284
+ } = {},
280
285
  ): Promise<ResolvedExecuteRequest> {
281
286
  validateExecuteRequestShape(request);
282
287
  const canonical = canonicalizeExecuteCommand(request.command);
283
- const cdbResolution = await resolveCdb(
284
- {
285
- targetInput: request.targetInput,
286
- cdbFile: request.cdbFile,
287
- cdbPassword: request.cdbPassword,
288
- },
289
- env,
290
- );
291
- const via = resolveExecuteProtocol(request, env, cdbResolution);
292
- const format = resolveFormat(request, env);
288
+ const config = options.config ?? (await loadEnvFileDefaults(env));
289
+ // Fan-out passes the pre-resolved CDB record for a member (the CDB is loaded
290
+ // once in `expandCdbSelection`); single-target resolves it here.
291
+ const cdbResolution =
292
+ options.cdbResolution ??
293
+ (await resolveCdb(
294
+ {
295
+ targetInput: request.targetInput,
296
+ cdbFile: request.cdbFile,
297
+ cdbPassword: request.cdbPassword,
298
+ },
299
+ env,
300
+ config,
301
+ ));
302
+ const via = resolveExecuteProtocol(request, env, cdbResolution, config);
303
+ const format = resolveFormat(request, env, config);
293
304
  const validate = resolveBooleanSetting(
294
305
  request.validate,
295
306
  env,
@@ -297,18 +308,22 @@ export async function resolveExecuteRequest(
297
308
  true,
298
309
  "validate",
299
310
  cdbResolution?.overrides.validate,
311
+ config,
300
312
  );
301
313
  const timeoutMs = resolveTimeoutSetting(
302
314
  request.timeout,
303
315
  env,
304
316
  via.value,
305
317
  cdbResolution?.overrides.timeoutMs,
318
+ config,
306
319
  );
307
320
  const maxResultsBytes = resolveOptionalIntegerSetting(
308
321
  request.maxResultsBytes,
309
322
  env,
310
323
  "CENTRS_MAX_RESULTS",
311
324
  "max-results",
325
+ undefined,
326
+ config,
312
327
  );
313
328
  const macResolution = isIpTransport(via.value)
314
329
  ? await resolveMacTarget({
@@ -316,7 +331,10 @@ export async function resolveExecuteRequest(
316
331
  targetInput: request.targetInput,
317
332
  cdbTarget: cdbResolution?.target,
318
333
  env,
319
- policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
334
+ config,
335
+ policy: parseResolvePolicy(
336
+ request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
337
+ ),
320
338
  operation: "execute",
321
339
  })
322
340
  : undefined;
@@ -330,6 +348,7 @@ export async function resolveExecuteRequest(
330
348
  env,
331
349
  via.value,
332
350
  cdbResolution,
351
+ config,
333
352
  );
334
353
  const auth = resolveAuth(
335
354
  {
@@ -339,6 +358,7 @@ export async function resolveExecuteRequest(
339
358
  },
340
359
  env,
341
360
  cdbResolution,
361
+ config,
342
362
  );
343
363
  const insecure = resolveBooleanSetting(
344
364
  request.insecure,
@@ -347,6 +367,7 @@ export async function resolveExecuteRequest(
347
367
  false,
348
368
  "insecure",
349
369
  cdbResolution?.overrides.insecure,
370
+ config,
350
371
  );
351
372
 
352
373
  return {
@@ -366,6 +387,79 @@ export async function resolveExecuteRequest(
366
387
  };
367
388
  }
368
389
 
390
+ export interface ExecuteGlobalContext {
391
+ via: RouterOsProtocol;
392
+ write: boolean;
393
+ summary: ExecuteRequestSummary;
394
+ settings: CommonSettingsMeta;
395
+ }
396
+
397
+ /**
398
+ * Resolve the target-independent settings (protocol, format, validate, timeout,
399
+ * max-results) for a fan-out's outer `meta.operation.request` summary and its
400
+ * transport-aware concurrency default. No CDB record is consulted; the global
401
+ * `--via` (default `native-api`) decides the summary and concurrency base. Also
402
+ * canonicalizes the command once so write-ness is known before any target runs.
403
+ */
404
+ export function resolveExecuteGlobalContext(
405
+ request: ExecuteRequest,
406
+ env: Record<string, string | undefined>,
407
+ config: Record<string, string | undefined> = {},
408
+ ): ExecuteGlobalContext {
409
+ validateExecuteRequestShape(request);
410
+ const canonical = canonicalizeExecuteCommand(request.command);
411
+ const via = resolveExecuteProtocol(request, env, undefined, config);
412
+ const format = resolveFormat(request, env, config);
413
+ const validate = resolveBooleanSetting(
414
+ request.validate,
415
+ env,
416
+ "CENTRS_VALIDATE",
417
+ true,
418
+ "validate",
419
+ undefined,
420
+ config,
421
+ );
422
+ const timeoutMs = resolveTimeoutSetting(
423
+ request.timeout,
424
+ env,
425
+ via.value,
426
+ undefined,
427
+ config,
428
+ );
429
+ const maxResultsBytes = resolveOptionalIntegerSetting(
430
+ request.maxResultsBytes,
431
+ env,
432
+ "CENTRS_MAX_RESULTS",
433
+ "max-results",
434
+ undefined,
435
+ config,
436
+ );
437
+ return {
438
+ via: via.value,
439
+ write: isWriteShaped(canonical),
440
+ summary: {
441
+ command: request.command,
442
+ canonical,
443
+ write: isWriteShaped(canonical),
444
+ yes: request.yes ?? false,
445
+ validate: validate.value,
446
+ verbose: request.verbose ?? false,
447
+ timeoutMs: timeoutMs.value,
448
+ format: format.value,
449
+ maxResultsBytes: maxResultsBytes?.value,
450
+ },
451
+ settings: {
452
+ via: toCoreSource(via.source),
453
+ timeoutMs: toCoreSource(timeoutMs.source),
454
+ format: toCoreSource(format.source),
455
+ validate: toCoreSource(validate.source),
456
+ maxResultsBytes: maxResultsBytes
457
+ ? toCoreSource(maxResultsBytes.source)
458
+ : undefined,
459
+ },
460
+ };
461
+ }
462
+
369
463
  export function buildExecuteErrorEnvelope(
370
464
  request: ExecuteRequest,
371
465
  error: unknown,
@@ -756,16 +850,6 @@ async function runCommand(
756
850
  return backend.execute({ path: "", command: "", script: resolved.command });
757
851
  }
758
852
  if (resolved.canonical.mode === "script") {
759
- if (resolved.via.value === "native-api") {
760
- throw new CentrsError({
761
- code: "transport/unsupported-operation",
762
- summary:
763
- "The native API execute path requires a slash-prefixed RouterOS command.",
764
- remediation:
765
- "Use a path-shaped command such as `/system/identity/set name=x`, or pin `--via rest-api` for `/rest/execute` script mode.",
766
- context: { via: resolved.via.value, command: resolved.command },
767
- });
768
- }
769
853
  return backend.execute({ path: "", command: "", script: resolved.command });
770
854
  }
771
855
  return backend.execute({
@@ -821,8 +905,9 @@ function resolveExecuteProtocol(
821
905
  request: ExecuteRequest,
822
906
  env: Record<string, string | undefined>,
823
907
  cdb?: CdbResolution,
908
+ config: Record<string, string | undefined> = {},
824
909
  ): ResolvedSetting<RouterOsProtocol> {
825
- const defaultVia = isUnresolvedMacTarget(request, env, cdb)
910
+ const defaultVia = isUnresolvedMacTarget(request, env, cdb, config)
826
911
  ? "mac-telnet"
827
912
  : "native-api";
828
913
  const via = resolveStringSetting(
@@ -833,6 +918,7 @@ function resolveExecuteProtocol(
833
918
  "via",
834
919
  undefined,
835
920
  cdb?.overrides.via,
921
+ config,
836
922
  );
837
923
  if (!via) {
838
924
  throw new CentrsError({
@@ -877,8 +963,13 @@ function isUnresolvedMacTarget(
877
963
  request: ExecuteRequest,
878
964
  env: Record<string, string | undefined>,
879
965
  cdb?: CdbResolution,
966
+ config: Record<string, string | undefined> = {},
880
967
  ): boolean {
881
- if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
968
+ if (
969
+ request.via !== undefined ||
970
+ env["CENTRS_VIA"] !== undefined ||
971
+ config["CENTRS_VIA"] !== undefined
972
+ ) {
882
973
  return false;
883
974
  }
884
975
  // Decide the default transport from the *effective* target (host >
@@ -890,6 +981,7 @@ function isUnresolvedMacTarget(
890
981
  targetInput: request.targetInput,
891
982
  cdbTarget: cdb?.target,
892
983
  env,
984
+ config,
893
985
  });
894
986
  return candidate ? isMacAddress(candidate) : false;
895
987
  }
@@ -897,6 +989,7 @@ function isUnresolvedMacTarget(
897
989
  function resolveFormat(
898
990
  request: ExecuteRequest,
899
991
  env: Record<string, string | undefined>,
992
+ config: Record<string, string | undefined> = {},
900
993
  ): ResolvedSetting<ExecuteOutputFormat> {
901
994
  return resolveStringSetting(
902
995
  request.format,
@@ -905,6 +998,8 @@ function resolveFormat(
905
998
  "text",
906
999
  "format",
907
1000
  parseOutputFormat,
1001
+ undefined,
1002
+ config,
908
1003
  ) as ResolvedSetting<ExecuteOutputFormat>;
909
1004
  }
910
1005
 
@@ -930,6 +1025,7 @@ function resolveTimeoutSetting(
930
1025
  env: Record<string, string | undefined>,
931
1026
  via: RouterOsProtocol,
932
1027
  commentKv?: ResolvedSetting<number>,
1028
+ config: Record<string, string | undefined> = {},
933
1029
  ): ResolvedSetting<number> {
934
1030
  const resolved = resolveStringSetting(
935
1031
  timeout === undefined ? undefined : String(timeout),
@@ -950,6 +1046,7 @@ function resolveTimeoutSetting(
950
1046
  return parsed;
951
1047
  },
952
1048
  commentKv,
1049
+ config,
953
1050
  );
954
1051
  if (!resolved) {
955
1052
  throw new Error("timeout resolution produced no value");