@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
@@ -84,6 +84,7 @@ export function resolveTarget(
84
84
  env: Record<string, string | undefined>,
85
85
  via: RouterOsProtocol,
86
86
  cdb?: CdbResolution,
87
+ config: Record<string, string | undefined> = {},
87
88
  ): ResolvedTarget {
88
89
  const hostSetting = resolveStringSetting(
89
90
  input.host,
@@ -91,6 +92,9 @@ export function resolveTarget(
91
92
  ENV_HOST,
92
93
  cdb?.target ?? input.targetInput,
93
94
  "host",
95
+ undefined,
96
+ undefined,
97
+ config,
94
98
  );
95
99
 
96
100
  if (!hostSetting || hostSetting.value.trim().length === 0) {
@@ -137,6 +141,9 @@ export function resolveTarget(
137
141
  ENV_HOST,
138
142
  undefined,
139
143
  "host",
144
+ undefined,
145
+ undefined,
146
+ config,
140
147
  );
141
148
  const macPortSetting = resolveOptionalIntegerSetting(
142
149
  input.port,
@@ -144,6 +151,7 @@ export function resolveTarget(
144
151
  ENV_PORT,
145
152
  "port",
146
153
  cdb?.overrides.port,
154
+ config,
147
155
  );
148
156
  // The device identity is the MAC (positional or CDB), NOT --host/CENTRS_HOST,
149
157
  // which are only the UDP delivery endpoint. Attribute meta.target.source to
@@ -217,6 +225,7 @@ export function resolveTarget(
217
225
  ENV_PORT,
218
226
  "port",
219
227
  cdb?.overrides.port,
228
+ config,
220
229
  );
221
230
  const scheme = parsedUrl.protocol === "https:" ? "https" : "http";
222
231
 
@@ -320,7 +329,14 @@ export function resolveAuth(
320
329
  credentials: { username?: string; password?: string; sshKey?: string },
321
330
  env: Record<string, string | undefined>,
322
331
  cdb?: CdbResolution,
332
+ config: Record<string, string | undefined> = {},
323
333
  ): ResolvedAuth {
334
+ // `CENTRS_USERNAME`/`CENTRS_PASSWORD` are deliberately NOT threaded through
335
+ // the `config` tier: `settings` refuses to write either (constitution: the
336
+ // `__default__` CDB record is the credential store), and the config-file
337
+ // loader itself does not surface them even when hand-added to the file —
338
+ // a stronger defense than read-time redaction alone, since a hand-edited
339
+ // credential line in `centrs.env` then can't function as a live source.
324
340
  const username = resolveStringSetting(
325
341
  credentials.username,
326
342
  env,
@@ -328,8 +344,9 @@ export function resolveAuth(
328
344
  undefined,
329
345
  "username",
330
346
  );
331
- // SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv → unset.
332
- // Path only; the SFTP/SSH transport reads the file, centrs never stores material.
347
+ // SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv →
348
+ // config → unset. Path only; the SFTP/SSH transport reads the file, centrs
349
+ // never stores material.
333
350
  const sshKey = resolveStringSetting(
334
351
  credentials.sshKey,
335
352
  env,
@@ -338,6 +355,7 @@ export function resolveAuth(
338
355
  "ssh-key",
339
356
  undefined,
340
357
  cdb?.overrides.sshKey,
358
+ config,
341
359
  );
342
360
  const password = resolveStringSetting(
343
361
  credentials.password,
@@ -437,6 +455,6 @@ export function defaultPortForScheme(scheme: "http" | "https"): number {
437
455
  return scheme === "https" ? 443 : 80;
438
456
  }
439
457
 
440
- function formatHostForUrl(host: string): string {
458
+ export function formatHostForUrl(host: string): string {
441
459
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
442
460
  }
@@ -1,28 +1,33 @@
1
1
  /**
2
- * Group fanout for `retrieve`.
2
+ * Multi-target fan-out for `retrieve`.
3
3
  *
4
- * A `--group <name>` selector expands a CDB group into N targets and runs each
5
- * through the single-target retrieve tail (`runResolvedRetrieve`), collecting a
4
+ * A target selection (`--group` / `--where` / `--all` / `--default` / multiple
5
+ * positionals) expands to N members and runs each through the single-target
6
+ * retrieve tail (`buildResolvedRetrieve` → `runResolvedRetrieve`), collecting a
6
7
  * per-target inner `CentrsEnvelope` into the LOCKED `FanoutData` shape from
7
8
  * `src/core/envelope.ts`. The outer envelope's `ok` means the orchestration
8
9
  * produced a complete per-target result set; a per-target failure is an INNER
9
10
  * `ok: false` envelope, never an outer failure and never a thrown error out of
10
11
  * the batch.
11
12
  *
12
- * Design rules implemented here (see `commands/retrieve/README.md`):
13
- * - The CDB is loaded + decrypted ONCE for the whole fanout (group expansion).
14
- * - Each target is resolved + validated independently (validation is
15
- * per-target via live `/console/inspect`; schemas differ by version).
13
+ * Boundaries (see `commands/retrieve/README.md`, `docs/CONSTITUTION.md`), shared
14
+ * with `api-fanout.ts`:
15
+ * - The CDB is loaded + decrypted ONCE (`expandSelection`); a cdb member is
16
+ * resolved with its pre-resolved record, so `resolveCdb`'s `__default__`
17
+ * synthetic fallback never collides ad-hoc literals.
18
+ * - A `--default` / `__default__` member is guarded from being dialed as the
19
+ * literal hostname `"__default__"` — it fails that one target deterministically
20
+ * with `target/unresolved`.
21
+ * - Each target is resolved + validated independently (validation is per-target
22
+ * via live `/console/inspect`; schemas differ by version).
16
23
  * - Concurrency is a bounded worker pool; defaults are transport-aware
17
24
  * (REST 8, native-api 4) and overridable with `--concurrency`.
18
- * - Transient drops are retried with jittered backoff on an allowlisted
19
- * retryable-code set only; router-side and client-side errors are not.
20
- * - `data.targets[]` is ordered by CDB `recordIndex` regardless of completion
21
- * order; `summary` counts are derived from inner `ok`.
25
+ * - `data.targets[]` is ordered by CDB `recordIndex` (literals appended in
26
+ * positional order); `summary` counts are derived from inner `ok`.
22
27
  *
23
- * Outer `ok: false` is reserved for fanout failing BEFORE reliable per-target
28
+ * Outer `ok: false` is reserved for fan-out failing BEFORE reliable per-target
24
29
  * results exist (bad request shape, CDB decrypt failure); those throw out of
25
- * {@link retrieveGroup} and the CLI renders them with
30
+ * {@link retrieveFanout} and the CLI renders them with
26
31
  * {@link buildRetrieveFanoutErrorEnvelope}.
27
32
  */
28
33
 
@@ -33,19 +38,24 @@ import type {
33
38
  FanoutSummary,
34
39
  } from "./core/envelope.ts";
35
40
  import {
41
+ buildFanoutEnvelope,
42
+ buildFanoutResolveFailure,
43
+ commonVia,
36
44
  defaultFanoutSleep,
37
45
  resolveFanoutConcurrency,
38
- runBoundedPool,
39
- runWithRetry,
46
+ runFanout,
40
47
  summarizeFanout,
41
48
  } from "./core/fanout.ts";
42
49
  import { CentrsError, serializeCentrsError } from "./errors.ts";
43
50
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
44
51
  import {
45
- type CdbGroupExpansion,
46
- type CdbGroupResolveInput,
47
- type CdbResolution,
48
- expandCdbGroup,
52
+ expandSelection,
53
+ isDefaultRecordTarget,
54
+ loadEnvFileDefaults,
55
+ type SelectionExpansion,
56
+ type SelectionMember,
57
+ type SelectionResolveInput,
58
+ type TargetSelection,
49
59
  } from "./resolver/index.ts";
50
60
  import {
51
61
  buildResolvedRetrieve,
@@ -59,17 +69,29 @@ import {
59
69
  type RetrieveSuccessEnvelope,
60
70
  resolveMacForRetrieve,
61
71
  resolveRetrieveGlobalContext,
72
+ resolveRetrieveRequest,
62
73
  runResolvedRetrieve,
63
74
  toYaml,
64
75
  validateRetrieveRequestShape,
65
76
  } from "./retrieve.ts";
66
77
 
78
+ /** Compact description of the selector that produced the fan-out. */
79
+ export interface RetrieveSelectionSummary {
80
+ groups: readonly string[];
81
+ where: readonly string[];
82
+ all: boolean;
83
+ default: boolean;
84
+ positionals: readonly string[];
85
+ /** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
86
+ quickchr?: readonly string[];
87
+ }
88
+
67
89
  export interface RetrieveFanoutOperationMeta {
68
90
  kind: "fanout";
69
- group: string;
70
91
  concurrency: number;
71
92
  summary: FanoutSummary;
72
93
  request: RetrieveRequestSummary;
94
+ selection: RetrieveSelectionSummary;
73
95
  }
74
96
 
75
97
  export type RetrieveFanoutData = FanoutData<unknown, RetrieveOperationMeta>;
@@ -81,60 +103,113 @@ export type RetrieveFanoutErrorEnvelope =
81
103
  CentrsErrorEnvelope<RetrieveFanoutOperationMeta>;
82
104
 
83
105
  /** Test/override seams; production callers pass nothing. */
84
- export interface RetrieveGroupInternals {
106
+ export interface RetrieveFanoutInternals {
85
107
  /** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
86
108
  execute?: (
87
109
  resolved: ResolvedRetrieveRequest,
88
- recordIndex: number,
89
110
  ) => Promise<RetrieveSuccessEnvelope>;
90
- /** Group expansion. Defaults to the real CDB-backed expansion. */
111
+ /** Selection expansion. Defaults to the real CDB-backed expansion. */
91
112
  expand?: (
92
- input: CdbGroupResolveInput,
113
+ selection: TargetSelection,
114
+ input: SelectionResolveInput,
93
115
  env: Record<string, string | undefined>,
94
- ) => Promise<CdbGroupExpansion>;
116
+ config?: Record<string, string | undefined>,
117
+ ) => Promise<SelectionExpansion>;
95
118
  /** Backoff sleeper. Defaults to a real timer; tests pass a no-op. */
96
119
  sleep?: (ms: number) => Promise<void>;
97
120
  }
98
121
 
122
+ export interface RetrieveFanoutOptions {
123
+ concurrency?: number;
124
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
125
+ allowAdhoc?: boolean;
126
+ }
127
+
128
+ function summarizeSelection(
129
+ selection: TargetSelection,
130
+ ): RetrieveSelectionSummary {
131
+ return {
132
+ groups: selection.groups,
133
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
134
+ all: selection.all,
135
+ default: selection.default,
136
+ positionals: selection.positionals,
137
+ quickchr: selection.quickchr,
138
+ };
139
+ }
140
+
141
+ function memberTargetMeta(member: SelectionMember): {
142
+ input?: string;
143
+ identity?: string;
144
+ recordIndex?: number;
145
+ } {
146
+ if (member.kind === "cdb") {
147
+ return {
148
+ input: member.resolution.identity,
149
+ identity: member.resolution.identity,
150
+ recordIndex: member.recordIndex,
151
+ };
152
+ }
153
+ if (member.kind === "quickchr") {
154
+ return { input: member.name, identity: member.name };
155
+ }
156
+ return { input: member.input };
157
+ }
158
+
99
159
  /**
100
- * Run a `retrieve` group fanout. Always returns a success envelope when the
160
+ * Ad-hoc literal targets have no CDB record. `resolveRetrieveRequest` may borrow
161
+ * the `__default__` record's index when filling fallback creds, so the inner
162
+ * envelope would carry that borrowed `recordIndex` — making distinct literals
163
+ * collide on one index. Drop it for literal members and stamp the caller's `input`
164
+ * so the target stays identifiable in renderers (mirrors `api-fanout.ts`).
165
+ */
166
+ function relabelAdhocMember(
167
+ envelope: RetrieveEnvelope,
168
+ member: SelectionMember,
169
+ ): RetrieveEnvelope {
170
+ if (member.kind !== "literal") {
171
+ return envelope;
172
+ }
173
+ const { recordIndex: _borrowed, ...target } = envelope.meta.target;
174
+ return {
175
+ ...envelope,
176
+ meta: { ...envelope.meta, target: { ...target, input: member.input } },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Run a `retrieve` fan-out. Always returns a success envelope when the
101
182
  * orchestration completes (even if every target failed); throws only for
102
183
  * pre-flight failures (bad request shape, CDB decrypt) the caller renders as an
103
184
  * outer error.
104
185
  */
105
- export async function retrieveGroup(
186
+ export async function retrieveFanout(
106
187
  request: RetrieveRequest,
188
+ selection: TargetSelection,
107
189
  env: Record<string, string | undefined> = Bun.env,
108
- internals: RetrieveGroupInternals = {},
190
+ internals: RetrieveFanoutInternals = {},
191
+ options: RetrieveFanoutOptions = {},
109
192
  ): Promise<RetrieveFanoutEnvelope> {
110
- const group = request.group;
111
- if (group === undefined || group.length === 0) {
112
- throw new CentrsError({
113
- code: "usage/missing-group",
114
- summary: "retrieve group fanout requires a non-empty `--group` value.",
115
- remediation:
116
- "Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
117
- });
118
- }
119
-
120
193
  const attributeSelections = validateRetrieveRequestShape(request);
194
+ const config = await loadEnvFileDefaults(env);
121
195
  const global = resolveRetrieveGlobalContext(
122
196
  request,
123
197
  env,
124
198
  attributeSelections,
199
+ config,
125
200
  );
126
- if (request.concurrency !== undefined) {
127
- resolveFanoutConcurrency(request.concurrency, global.via);
128
- }
201
+ const selectionSummary = summarizeSelection(selection);
129
202
 
130
- const expand = internals.expand ?? expandCdbGroup;
203
+ const expand = internals.expand ?? expandSelection;
131
204
  const expansion = await expand(
205
+ selection,
132
206
  {
133
- group,
134
207
  cdbFile: request.cdbFile,
135
208
  cdbPassword: request.cdbPassword,
209
+ allowAdhoc: options.allowAdhoc ?? true,
136
210
  },
137
211
  env,
212
+ config,
138
213
  );
139
214
 
140
215
  const concurrencyVia = resolveFanoutConcurrencyProtocol(
@@ -144,13 +219,13 @@ export async function retrieveGroup(
144
219
  expansion,
145
220
  );
146
221
  const concurrency = resolveFanoutConcurrency(
147
- request.concurrency,
222
+ options.concurrency ?? request.concurrency,
148
223
  concurrencyVia,
149
224
  );
150
225
 
151
226
  if (expansion.empty) {
152
- return buildFanoutEnvelope({
153
- group,
227
+ return retrieveFanoutEnvelope({
228
+ selection: selectionSummary,
154
229
  concurrency,
155
230
  summary: { total: 0, ok: 0, failed: 0 },
156
231
  requestSummary: global.summary,
@@ -164,40 +239,76 @@ export async function retrieveGroup(
164
239
  const execute = internals.execute ?? runResolvedRetrieve;
165
240
  const sleep = internals.sleep ?? defaultFanoutSleep;
166
241
 
167
- const targets = await runBoundedPool(
168
- expansion.targets,
242
+ const targets = await runFanout<
243
+ SelectionMember,
244
+ ResolvedRetrieveRequest,
245
+ RetrieveEnvelope
246
+ >({
247
+ members: expansion.targets,
169
248
  concurrency,
170
- async (member): Promise<RetrieveEnvelope> => {
171
- let resolved: ResolvedRetrieveRequest;
172
- try {
249
+ resolve: async (member) => {
250
+ if (member.kind === "cdb") {
251
+ if (isDefaultRecordTarget(member.resolution.target)) {
252
+ throw new CentrsError({
253
+ code: "target/unresolved",
254
+ summary:
255
+ "The `__default__` record is a credential fallback, not a connectable router.",
256
+ remediation:
257
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
258
+ context: { target: member.resolution.target },
259
+ });
260
+ }
173
261
  const macResolution = await resolveMacForRetrieve(
174
262
  request,
175
263
  env,
176
264
  member.resolution,
265
+ config,
177
266
  );
178
- resolved = buildResolvedRetrieve(
267
+ return buildResolvedRetrieve(
179
268
  request,
180
269
  env,
181
270
  member.resolution,
182
271
  attributeSelections,
183
272
  macResolution,
273
+ config,
274
+ );
275
+ }
276
+ if (member.kind === "quickchr") {
277
+ // The live descriptor resolves here, per member — a stopped/unknown
278
+ // machine is an inner per-target failure, not an expansion error.
279
+ return resolveRetrieveRequest(
280
+ { ...request, targetInput: undefined, quickchr: member.name },
281
+ env,
184
282
  );
185
- } catch (error) {
186
- return buildResolveFailureEnvelope(member.resolution, error);
187
283
  }
188
- return runWithRetry<RetrieveEnvelope>(
189
- () => execute(resolved, member.recordIndex),
190
- (error) => buildRetrieveErrorEnvelopeFromResolved(resolved, error),
191
- { sleep },
284
+ // Ad-hoc literal: full single-target resolution (CDB load → `__default__`
285
+ // fallback creds), mirroring single-target `retrieve`.
286
+ return resolveRetrieveRequest(
287
+ { ...request, targetInput: member.input },
288
+ env,
192
289
  );
193
290
  },
194
- );
291
+ onResolveError: (member, error) =>
292
+ buildFanoutResolveFailure<RetrieveOperationMeta>({
293
+ error,
294
+ target: memberTargetMeta(member),
295
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
296
+ summary: "Failed to resolve a fanout target.",
297
+ }),
298
+ execute: async (resolved, member) =>
299
+ relabelAdhocMember(await execute(resolved), member),
300
+ onExecuteError: (resolved, member, error) =>
301
+ relabelAdhocMember(
302
+ buildRetrieveErrorEnvelopeFromResolved(resolved, error),
303
+ member,
304
+ ),
305
+ sleep,
306
+ });
195
307
 
196
- const summary = summarizeFanout(targets);
197
- return buildFanoutEnvelope({
198
- group,
308
+ return retrieveFanoutEnvelope({
309
+ selection: selectionSummary,
199
310
  concurrency,
200
- summary,
311
+ summary: summarizeFanout(targets),
201
312
  requestSummary: global.summary,
202
313
  settings: global.settings,
203
314
  via: commonVia(targets),
@@ -206,6 +317,37 @@ export async function retrieveGroup(
206
317
  });
207
318
  }
208
319
 
320
+ /**
321
+ * Back-compat shim for the `--group`-only fan-out: builds a single-group
322
+ * {@link TargetSelection} and delegates to {@link retrieveFanout}. Kept for the
323
+ * MCP `centrs_retrieve` tool, whose surface exposes only `group`.
324
+ */
325
+ export async function retrieveGroup(
326
+ request: RetrieveRequest,
327
+ env: Record<string, string | undefined> = Bun.env,
328
+ internals: RetrieveFanoutInternals = {},
329
+ ): Promise<RetrieveFanoutEnvelope> {
330
+ const group = request.group;
331
+ if (group === undefined || group.length === 0) {
332
+ throw new CentrsError({
333
+ code: "usage/missing-group",
334
+ summary: "retrieve group fanout requires a non-empty `--group` value.",
335
+ remediation:
336
+ "Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
337
+ });
338
+ }
339
+ const selection: TargetSelection = {
340
+ positionals: [],
341
+ groups: [group],
342
+ all: false,
343
+ default: false,
344
+ where: [],
345
+ };
346
+ return retrieveFanout(request, selection, env, internals, {
347
+ concurrency: request.concurrency,
348
+ });
349
+ }
350
+
209
351
  /** Outer error envelope for a fanout that failed before per-target results. */
210
352
  export function buildRetrieveFanoutErrorEnvelope(
211
353
  request: RetrieveRequest,
@@ -216,7 +358,7 @@ export function buildRetrieveFanoutErrorEnvelope(
216
358
  ? error
217
359
  : new CentrsError({
218
360
  code: "internal/unhandled",
219
- summary: "retrieve group fanout failed with an unexpected error.",
361
+ summary: "retrieve fan-out failed with an unexpected error.",
220
362
  remediation:
221
363
  "Re-run with `--format json` to capture the structured error details for debugging.",
222
364
  cause: error,
@@ -245,14 +387,20 @@ function resolveFanoutConcurrencyProtocol(
245
387
  request: RetrieveRequest,
246
388
  env: Record<string, string | undefined>,
247
389
  globalVia: RouterOsProtocol,
248
- expansion: CdbGroupExpansion,
390
+ expansion: SelectionExpansion,
249
391
  ): RouterOsProtocol {
392
+ // Deliberately CLI/env only, not `config` — a `centrs.env` default is the
393
+ // weakest precedence tier and must not be treated as a hard pin the way an
394
+ // explicit `--via`/`CENTRS_VIA` is; doing so would defeat per-target CDB
395
+ // `via=` overrides for members that disagree with the global default.
250
396
  if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
251
397
  return globalVia;
252
398
  }
253
399
  if (
254
400
  expansion.targets.some(
255
- (target) => target.resolution.overrides.via?.value === "native-api",
401
+ (member) =>
402
+ member.kind === "cdb" &&
403
+ member.resolution.overrides.via?.value === "native-api",
256
404
  )
257
405
  ) {
258
406
  return "native-api";
@@ -260,37 +408,8 @@ function resolveFanoutConcurrencyProtocol(
260
408
  return globalVia;
261
409
  }
262
410
 
263
- function buildResolveFailureEnvelope(
264
- resolution: CdbResolution,
265
- error: unknown,
266
- ): RetrieveEnvelope {
267
- const centrsError =
268
- error instanceof CentrsError
269
- ? error
270
- : new CentrsError({
271
- code: "internal/unhandled",
272
- summary: "Failed to resolve a fanout target.",
273
- cause: error,
274
- });
275
- return {
276
- ok: false,
277
- error: serializeCentrsError(centrsError),
278
- warnings: [...resolution.warnings],
279
- tips: [],
280
- meta: {
281
- target: {
282
- input: resolution.identity,
283
- identity: resolution.identity,
284
- recordIndex: resolution.recordIndex,
285
- },
286
- via: null,
287
- settings: {},
288
- },
289
- };
290
- }
291
-
292
411
  interface FanoutEnvelopeInput {
293
- group: string;
412
+ selection: RetrieveSelectionSummary;
294
413
  concurrency: number;
295
414
  summary: FanoutSummary;
296
415
  requestSummary: RetrieveRequestSummary;
@@ -300,45 +419,28 @@ interface FanoutEnvelopeInput {
300
419
  targets: readonly RetrieveEnvelope[];
301
420
  }
302
421
 
303
- function buildFanoutEnvelope(
422
+ /** retrieve's outer fan-out success envelope (delegates to the shared core). */
423
+ function retrieveFanoutEnvelope(
304
424
  input: FanoutEnvelopeInput,
305
425
  ): RetrieveFanoutEnvelope {
306
- return {
307
- ok: true,
308
- data: {
309
- summary: input.summary,
310
- targets: input.targets,
311
- },
426
+ return buildFanoutEnvelope<
427
+ unknown,
428
+ RetrieveOperationMeta,
429
+ RetrieveFanoutOperationMeta
430
+ >({
431
+ summary: input.summary,
432
+ targets: input.targets,
312
433
  warnings: input.warnings,
313
- tips: [],
314
- meta: {
315
- target: {},
316
- via: input.via,
317
- settings: input.settings,
318
- operation: {
319
- kind: "fanout",
320
- group: input.group,
321
- concurrency: input.concurrency,
322
- summary: input.summary,
323
- request: input.requestSummary,
324
- },
434
+ settings: input.settings,
435
+ via: input.via,
436
+ operation: {
437
+ kind: "fanout",
438
+ concurrency: input.concurrency,
439
+ summary: input.summary,
440
+ request: input.requestSummary,
441
+ selection: input.selection,
325
442
  },
326
- };
327
- }
328
-
329
- function commonVia(
330
- targets: readonly RetrieveEnvelope[],
331
- ): RouterOsProtocol | null {
332
- let via: RouterOsProtocol | null | undefined;
333
- for (const target of targets) {
334
- const candidate = target.meta.via;
335
- if (via === undefined) {
336
- via = candidate;
337
- } else if (via !== candidate) {
338
- return null;
339
- }
340
- }
341
- return via ?? null;
443
+ });
342
444
  }
343
445
 
344
446
  /**
@@ -376,12 +478,16 @@ function renderFanoutText(
376
478
  const operation = envelope.meta.operation;
377
479
  const { total, ok, failed } = envelope.data.summary;
378
480
  const lines: string[] = [
379
- `group ${operation?.group ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
481
+ `retrieve ${operation?.request.path ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
380
482
  ];
381
483
 
382
484
  for (const target of envelope.data.targets) {
383
485
  const meta = target.meta;
384
- const label = meta.target.identity ?? meta.target.host ?? "(unknown)";
486
+ const label =
487
+ meta.target.identity ??
488
+ meta.target.host ??
489
+ meta.target.input ??
490
+ "(unknown)";
385
491
  const index = meta.target.recordIndex ?? "-";
386
492
  if (target.ok) {
387
493
  lines.push(