@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,597 @@
1
+ /**
2
+ * The quickchr named-live-provider (#134): resolve `--quickchr <name>` into the
3
+ * neutral per-service connection facts (`./service-endpoint.ts`).
4
+ *
5
+ * quickchr is a *named-live-provider* (see `docs/CONSTITUTION.md` → Resolution
6
+ * providers), not a selector-source: it is addressed only by an explicit flag,
7
+ * never matched by `--group`/`--where`, and it owns an ephemeral endpoint. This
8
+ * module produces the {@link ServiceEndpointMap} for a running machine and maps
9
+ * quickchr's typed failures onto centrs' `quickchr/*` errors. Selection, fan-out,
10
+ * `--via` choice, and the settings/`__default__` bypass live with the CLI plumbing
11
+ * (#134 Phase 3+); this file is only the connection-fact origin.
12
+ *
13
+ * Boundaries (`tikoci/quickchr` `docs/centrs-interface.md`):
14
+ * - quickchr is imported through a **runtime specifier** (as in
15
+ * `scripts/qa-active-channels.ts` / `test/integration/chr.ts`), so the
16
+ * optional dependency's raw `.ts` never enters centrs' strict `tsc`, and a
17
+ * missing package is a friendly typed error rather than a build/import crash.
18
+ * - centrs reads quickchr's public API only (`QuickCHR.get(name).descriptor()`);
19
+ * it never touches `machine.json`, `quickchr.env`, or the credential store.
20
+ * - The descriptor is credential-bearing; its `auth` values are secret material
21
+ * and must never be logged.
22
+ */
23
+
24
+ import { CentrsError } from "../errors.ts";
25
+ import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
26
+ import type {
27
+ AnyServiceEndpoint,
28
+ ServiceEndpointMap,
29
+ SshEndpointAuth,
30
+ } from "./service-endpoint.ts";
31
+ import { isEndpointAvailable } from "./service-endpoint.ts";
32
+ import type { ResolvedAuth, ResolvedTarget } from "./target.ts";
33
+ import { formatHostForUrl } from "./target.ts";
34
+
35
+ /** Highest quickchr descriptor schema version this centrs understands. */
36
+ export const SUPPORTED_DESCRIPTOR_VERSION = 1;
37
+
38
+ const QUICKCHR_MODULE = "@tikoci/quickchr";
39
+
40
+ /**
41
+ * The narrow slice of quickchr's public API this consumer depends on. Declared
42
+ * here (not imported) so the runtime-specifier `import()` keeps quickchr's types
43
+ * out of `tsc`; the shapes mirror `@tikoci/quickchr` `docs/centrs-interface.md`.
44
+ */
45
+ interface QuickChrInstance {
46
+ // The descriptor crosses an optional-dependency boundary, so it is `unknown`
47
+ // until validated by `validateDescriptor()` — never trust its runtime shape.
48
+ descriptor(): Promise<unknown>;
49
+ }
50
+
51
+ interface QuickChrModule {
52
+ QuickCHR?: {
53
+ get?(name: string): QuickChrInstance | null | undefined;
54
+ };
55
+ }
56
+
57
+ /** The descriptor fields centrs reads. quickchr's `ServiceEndpoint` is structurally
58
+ * identical to centrs' neutral {@link AnyServiceEndpoint} (deliberately shared). */
59
+ interface QuickchrDescriptor {
60
+ descriptorVersion: number;
61
+ quickchr?: { packageVersion?: string };
62
+ name: string;
63
+ version: string;
64
+ arch: string;
65
+ services: Record<string, AnyServiceEndpoint>;
66
+ }
67
+
68
+ /** A quickchr error carries a typed `code` (`ErrorCode`) we can branch on. */
69
+ function quickchrErrorCode(error: unknown): string | undefined {
70
+ if (
71
+ typeof error === "object" &&
72
+ error !== null &&
73
+ "code" in error &&
74
+ typeof (error as { code: unknown }).code === "string"
75
+ ) {
76
+ return (error as { code: string }).code;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Resolved quickchr connection facts for one running machine. */
82
+ export interface QuickchrResolution {
83
+ /** The machine name quickchr reports (echoes the `--quickchr <name>` input). */
84
+ name: string;
85
+ /** Per-`--via` endpoints; only protocols quickchr forwards are present. */
86
+ services: ServiceEndpointMap;
87
+ /** The descriptor schema version quickchr emitted (≤ {@link SUPPORTED_DESCRIPTOR_VERSION}). */
88
+ descriptorVersion: number;
89
+ /** The `@tikoci/quickchr` package version that produced the descriptor, when reported. */
90
+ packageVersion?: string;
91
+ /** The RouterOS version the CHR is running. */
92
+ routerOsVersion: string;
93
+ /** The CHR architecture (e.g. `x86_64`). */
94
+ arch: string;
95
+ }
96
+
97
+ async function loadQuickChr(): Promise<QuickChrModule> {
98
+ return (await import(QUICKCHR_MODULE)) as unknown as QuickChrModule;
99
+ }
100
+
101
+ function packageUnavailable(cause: unknown): CentrsError {
102
+ return new CentrsError({
103
+ code: "quickchr/package-unavailable",
104
+ summary: `The optional \`${QUICKCHR_MODULE}\` package is required for \`--quickchr\` targets but is not installed.`,
105
+ remediation: `Install it with \`bun add ${QUICKCHR_MODULE}\` (it is an optional dependency), or target the device directly with \`--host\`/a CDB record instead of \`--quickchr\`.`,
106
+ context: { module: QUICKCHR_MODULE },
107
+ cause,
108
+ });
109
+ }
110
+
111
+ /** A `quickchr/unsupported` for an old/malformed descriptor the provider can't trust. */
112
+ function malformedDescriptor(
113
+ name: string,
114
+ detail: string,
115
+ extra?: { context?: Record<string, unknown>; cause?: unknown },
116
+ ): CentrsError {
117
+ return new CentrsError({
118
+ code: "quickchr/unsupported",
119
+ summary: `quickchr described machine "${name}" with a descriptor centrs cannot use: ${detail}.`,
120
+ remediation: `Upgrade to a \`${QUICKCHR_MODULE}\` release that emits descriptor v${SUPPORTED_DESCRIPTOR_VERSION} (0.4.5+); check the machine with \`quickchr inspect ${name}\`.`,
121
+ context: { module: QUICKCHR_MODULE, machine: name, ...extra?.context },
122
+ cause: extra?.cause,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Distinguish a *missing* optional package from one that is installed but failed
128
+ * to evaluate. Only a failure to resolve `@tikoci/quickchr` **itself** is
129
+ * `quickchr/package-unavailable`; an installed quickchr whose own transitive
130
+ * import is missing carries the same `ERR_MODULE_NOT_FOUND` code but names a
131
+ * different specifier — that is a real load fault (→ `quickchr/unsupported`,
132
+ * cause preserved), not a missing optional dep. Mislabeling it would send the
133
+ * user to reinstall the wrong package.
134
+ */
135
+ function isModuleNotFound(cause: unknown): boolean {
136
+ const message = cause instanceof Error ? cause.message : String(cause);
137
+ // The loader quotes the specifier it could not resolve, e.g.
138
+ // `Cannot find module '@tikoci/quickchr'` (top-level, missing) vs
139
+ // `Cannot find package 'left-pad' imported from …/@tikoci/quickchr/…` (transitive).
140
+ const missing = message.match(
141
+ /cannot find (?:module|package)\s+['"]([^'"]+)['"]/i,
142
+ )?.[1];
143
+ if (missing !== undefined) {
144
+ return missing === QUICKCHR_MODULE;
145
+ }
146
+ // No parseable specifier: fall back to the not-found code, but only when the
147
+ // message references our module and not a nested (`imported from`) resolution.
148
+ const code = quickchrErrorCode(cause);
149
+ const isNotFoundCode =
150
+ code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
151
+ return (
152
+ isNotFoundCode &&
153
+ message.includes(QUICKCHR_MODULE) &&
154
+ !/imported from|from\s+['"]/i.test(message)
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Validate the untrusted descriptor an optional-dependency call returned into the
160
+ * shape centrs reads. Every malformed/old/newer case becomes an actionable
161
+ * `quickchr/unsupported` — never a raw `TypeError` from a later field access, and
162
+ * never a silent empty `services` map (that would hide a provider contract
163
+ * violation and defer the failure to a `--via` lookup).
164
+ */
165
+ function validateDescriptor(raw: unknown, name: string): QuickchrDescriptor {
166
+ if (typeof raw !== "object" || raw === null) {
167
+ throw malformedDescriptor(
168
+ name,
169
+ "quickchr returned a non-object descriptor",
170
+ );
171
+ }
172
+ const d = raw as Partial<QuickchrDescriptor>;
173
+ if (typeof d.descriptorVersion !== "number") {
174
+ throw malformedDescriptor(
175
+ name,
176
+ "it has no numeric `descriptorVersion` (pre-v1 or malformed)",
177
+ );
178
+ }
179
+ if (d.descriptorVersion > SUPPORTED_DESCRIPTOR_VERSION) {
180
+ throw new CentrsError({
181
+ code: "quickchr/unsupported",
182
+ summary: `quickchr emitted descriptor v${d.descriptorVersion}, but this centrs understands only v${SUPPORTED_DESCRIPTOR_VERSION}.`,
183
+ remediation:
184
+ "Upgrade centrs to a release that supports the newer quickchr descriptor version.",
185
+ context: {
186
+ machine: name,
187
+ descriptorVersion: d.descriptorVersion,
188
+ supported: SUPPORTED_DESCRIPTOR_VERSION,
189
+ },
190
+ });
191
+ }
192
+ if (typeof d.services !== "object" || d.services === null) {
193
+ throw malformedDescriptor(
194
+ name,
195
+ "it has no `services` map (the quickchr contract requires one)",
196
+ );
197
+ }
198
+ if (
199
+ typeof d.name !== "string" ||
200
+ typeof d.version !== "string" ||
201
+ typeof d.arch !== "string"
202
+ ) {
203
+ throw malformedDescriptor(
204
+ name,
205
+ "it is missing required `name`/`version`/`arch` fields",
206
+ );
207
+ }
208
+ return d as QuickchrDescriptor;
209
+ }
210
+
211
+ /**
212
+ * Map quickchr's `services` map onto the neutral {@link ServiceEndpointMap}. Only
213
+ * keys that are known centrs `--via` protocols are carried; unknown future service
214
+ * keys are ignored (the descriptor's additive-only forward-compat policy). The
215
+ * endpoint objects are structurally centrs' own shape, so no per-field rewrite is
216
+ * needed here — provider-specific auth mapping (e.g. `privateKeyPath` → `sshKey`)
217
+ * happens later when an endpoint is turned into a `ResolvedAuth` for a chosen `--via`.
218
+ */
219
+ function mapServices(
220
+ services: Record<string, AnyServiceEndpoint>,
221
+ ): ServiceEndpointMap {
222
+ const map: ServiceEndpointMap = {};
223
+ const known = new Set<string>(plannedProtocols);
224
+ for (const [key, endpoint] of Object.entries(services)) {
225
+ if (known.has(key)) {
226
+ // The per-key map narrows `ssh` to SshServiceEndpoint; the provider-supplied
227
+ // endpoint is structurally correct per the descriptor contract, so assign
228
+ // through a widened view rather than re-validating each field here.
229
+ (map as Record<RouterOsProtocol, AnyServiceEndpoint>)[
230
+ key as RouterOsProtocol
231
+ ] = endpoint;
232
+ }
233
+ }
234
+ return map;
235
+ }
236
+
237
+ /**
238
+ * Resolve a `--quickchr <name>` target into its live connection facts. Throws a
239
+ * typed `quickchr/*` error for every failure mode — a missing package, an
240
+ * unsupported/old quickchr, an unknown machine name, or a stopped machine — never
241
+ * a silent fallback. The `load` seam is for unit tests; production passes nothing.
242
+ */
243
+ export async function resolveQuickchrTarget(
244
+ name: string,
245
+ load: () => Promise<QuickChrModule> = loadQuickChr,
246
+ ): Promise<QuickchrResolution> {
247
+ let mod: QuickChrModule;
248
+ try {
249
+ mod = await load();
250
+ } catch (cause) {
251
+ // A CentrsError from the loader (or downstream) passes through unchanged.
252
+ if (cause instanceof CentrsError) {
253
+ throw cause;
254
+ }
255
+ // Only a genuine module-not-found is "package not installed"; an installed
256
+ // package that throws while loading is a real fault, not a missing optional dep.
257
+ if (isModuleNotFound(cause)) {
258
+ throw packageUnavailable(cause);
259
+ }
260
+ throw new CentrsError({
261
+ code: "quickchr/unsupported",
262
+ summary: `The \`${QUICKCHR_MODULE}\` package is installed but failed to load.`,
263
+ remediation: `Reinstall it (\`bun add ${QUICKCHR_MODULE}\`) or inspect the underlying load error; target the device with \`--host\`/a CDB record to bypass \`--quickchr\`.`,
264
+ context: { module: QUICKCHR_MODULE },
265
+ cause,
266
+ });
267
+ }
268
+ if (typeof mod?.QuickCHR?.get !== "function") {
269
+ throw new CentrsError({
270
+ code: "quickchr/unsupported",
271
+ summary: `The installed \`${QUICKCHR_MODULE}\` does not expose the \`QuickCHR.get()\` descriptor API centrs needs.`,
272
+ remediation: `Upgrade to a \`${QUICKCHR_MODULE}\` release that ships descriptor v${SUPPORTED_DESCRIPTOR_VERSION} (0.4.5+).`,
273
+ context: { module: QUICKCHR_MODULE },
274
+ });
275
+ }
276
+
277
+ const instance = mod.QuickCHR.get(name);
278
+ if (instance === null || instance === undefined) {
279
+ throw new CentrsError({
280
+ code: "quickchr/machine-not-found",
281
+ summary: `No quickchr machine named "${name}" is registered.`,
282
+ remediation:
283
+ "List machines with `quickchr list` and pass an existing name, or start it with `quickchr start`.",
284
+ context: { machine: name },
285
+ });
286
+ }
287
+
288
+ let raw: unknown;
289
+ try {
290
+ raw = await instance.descriptor();
291
+ } catch (cause) {
292
+ const code = quickchrErrorCode(cause);
293
+ if (code === "MACHINE_STOPPED") {
294
+ throw new CentrsError({
295
+ code: "quickchr/machine-stopped",
296
+ summary: `The quickchr machine "${name}" is not running, so it has no live connection facts.`,
297
+ remediation: `Start it with \`quickchr start ${name}\` (or by name), then retry \`--quickchr ${name}\`.`,
298
+ context: { machine: name },
299
+ cause,
300
+ });
301
+ }
302
+ if (code === "MACHINE_NOT_FOUND") {
303
+ throw new CentrsError({
304
+ code: "quickchr/machine-not-found",
305
+ summary: `No quickchr machine named "${name}" is registered.`,
306
+ remediation:
307
+ "List machines with `quickchr list` and pass an existing name, or start it with `quickchr start`.",
308
+ context: { machine: name },
309
+ cause,
310
+ });
311
+ }
312
+ throw new CentrsError({
313
+ code: "quickchr/unsupported",
314
+ summary: `quickchr failed to describe machine "${name}".`,
315
+ remediation:
316
+ "Check `quickchr inspect <name>` directly; the machine may be in a transient state.",
317
+ context: { machine: name, quickchrCode: code },
318
+ cause,
319
+ });
320
+ }
321
+
322
+ const descriptor = validateDescriptor(raw, name);
323
+
324
+ return {
325
+ name: descriptor.name,
326
+ services: mapServices(descriptor.services),
327
+ descriptorVersion: descriptor.descriptorVersion,
328
+ packageVersion: descriptor.quickchr?.packageVersion,
329
+ routerOsVersion: descriptor.version,
330
+ arch: descriptor.arch,
331
+ };
332
+ }
333
+
334
+ /**
335
+ * The `--quickchr` ↔ direct-override conflict (#134 "Initial override rule"):
336
+ * a named-live-provider owns its ephemeral endpoint, so explicit `--host` /
337
+ * `--port` / `--username` / `--password` / `--ssh-key` values have nothing to
338
+ * override and are rejected up front rather than silently ignored. Env/config
339
+ * values for the same fields are NOT a conflict — the provider simply bypasses
340
+ * the settings ladder for connection facts (`docs/CONSTITUTION.md` →
341
+ * Resolution providers).
342
+ */
343
+ export function assertNoQuickchrOverrideConflict(
344
+ request: {
345
+ host?: string;
346
+ port?: number;
347
+ username?: string;
348
+ password?: string;
349
+ sshKey?: string;
350
+ },
351
+ machine: string,
352
+ ): void {
353
+ const conflicts: string[] = [];
354
+ if (request.host !== undefined) {
355
+ conflicts.push("--host");
356
+ }
357
+ if (request.port !== undefined) {
358
+ conflicts.push("--port");
359
+ }
360
+ if (request.username !== undefined) {
361
+ conflicts.push("--username");
362
+ }
363
+ if (request.password !== undefined) {
364
+ conflicts.push("--password");
365
+ }
366
+ if (request.sshKey !== undefined) {
367
+ conflicts.push("--ssh-key");
368
+ }
369
+ if (conflicts.length === 0) {
370
+ return;
371
+ }
372
+ throw new CentrsError({
373
+ code: "usage/conflicting-flags",
374
+ summary: `\`--quickchr ${machine}\` supplies host/port/auth from the live VM descriptor and cannot be combined with ${conflicts.join(", ")}.`,
375
+ remediation:
376
+ "Drop the override flag(s) and let quickchr supply the connection facts, or drop `--quickchr` and target the device directly.",
377
+ context: { machine, conflicts },
378
+ });
379
+ }
380
+
381
+ /** The connection facts a chosen `--via` extracts from a quickchr resolution. */
382
+ export interface QuickchrConnection {
383
+ target: ResolvedTarget;
384
+ auth: ResolvedAuth;
385
+ /**
386
+ * True when centrs must relax peer verification to use the endpoint: the
387
+ * descriptor prefers the VM's TLS forwards (https / api-ssl), whose
388
+ * certificate is the CHR's own self-signed one, and an SSH endpoint's host
389
+ * key is ephemeral (regenerated per VM). The endpoint is trusted **by
390
+ * provenance** — quickchr vouches for its loopback-forwarded port — so the
391
+ * command resolver applies `insecure` with `provider` provenance and surfaces
392
+ * {@link warnings} rather than demanding `--insecure` for every lab call.
393
+ */
394
+ insecure: boolean;
395
+ /** Envelope warnings the consuming resolver must carry (provider-trust note). */
396
+ warnings: readonly { code: string; message: string }[];
397
+ }
398
+
399
+ /**
400
+ * True when the SSH endpoint's advertised auth can actually complete a batch
401
+ * (non-interactive) handoff: `agent-or-config`, or `private-key` **with** a
402
+ * `privateKeyPath` to hand to the client. A bare `private-key` batch mode with
403
+ * no path would silently fall back to whatever ambient agent/config offers —
404
+ * an unintended trust widening — so it does not count as usable.
405
+ */
406
+ function hasUsableSshAuth(auth: SshEndpointAuth | undefined): boolean {
407
+ const batchModes = auth?.batchModes ?? [];
408
+ return (
409
+ batchModes.includes("agent-or-config") ||
410
+ (batchModes.includes("private-key") && auth?.privateKeyPath !== undefined)
411
+ );
412
+ }
413
+
414
+ function unsupportedVia(
415
+ resolution: QuickchrResolution,
416
+ via: RouterOsProtocol,
417
+ detail: string,
418
+ context: Record<string, unknown> = {},
419
+ ): CentrsError {
420
+ // Alternatives must be actually dialable: never re-suggest the `--via` that
421
+ // just failed, and only advertise `ssh` when its auth passes the same batch
422
+ // gate {@link buildConnection} enforces.
423
+ const available = (
424
+ Object.entries(resolution.services) as [
425
+ RouterOsProtocol,
426
+ AnyServiceEndpoint,
427
+ ][]
428
+ )
429
+ .filter(
430
+ ([id, endpoint]) =>
431
+ id !== via &&
432
+ endpoint.available &&
433
+ (id !== "ssh" ||
434
+ hasUsableSshAuth(endpoint.auth as SshEndpointAuth | undefined)),
435
+ )
436
+ .map(([id]) => id);
437
+ return new CentrsError({
438
+ code: "quickchr/unsupported-via",
439
+ summary: `quickchr machine "${resolution.name}" cannot serve \`--via ${via}\`: ${detail}.`,
440
+ remediation:
441
+ available.length > 0
442
+ ? `Pick one of the machine's live services with \`--via ${available.join("` / `--via ")}\`, or target the device directly without \`--quickchr\`.`
443
+ : `The machine reports no live services; check it with \`quickchr inspect ${resolution.name}\`.`,
444
+ context: {
445
+ machine: resolution.name,
446
+ via,
447
+ availableServices: available,
448
+ ...context,
449
+ },
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Materialize the connection facts for one chosen `--via` from a resolved
455
+ * quickchr machine — the named-live-provider consumption seam (#134 Phase 4).
456
+ * Substitutes for `resolveTarget`/`resolveAuth`: host/port/TLS/auth come ONLY
457
+ * from the descriptor's per-service endpoint (never the CDB, env, or the
458
+ * `__default__` ladder), stamped with `provider` provenance.
459
+ *
460
+ * Failure modes are all `quickchr/unsupported-via`, never a silent fallback:
461
+ * a `--via` the descriptor does not forward (quickchr v1 forwards
462
+ * `rest-api`/`native-api`/`ssh` only), an endpoint quickchr marked
463
+ * unavailable, or an SSH endpoint with no batch-capable (non-interactive)
464
+ * auth — centrs must not password-prompt mid-fan-out.
465
+ */
466
+ export function quickchrConnection(
467
+ resolution: QuickchrResolution,
468
+ via: RouterOsProtocol,
469
+ ): QuickchrConnection {
470
+ const endpoint = resolution.services[via];
471
+ if (endpoint === undefined) {
472
+ throw unsupportedVia(
473
+ resolution,
474
+ via,
475
+ "the descriptor does not forward that service (quickchr v1 forwards rest-api/native-api/ssh)",
476
+ );
477
+ }
478
+ return buildConnection(resolution, via, endpoint);
479
+ }
480
+
481
+ function buildConnection(
482
+ resolution: QuickchrResolution,
483
+ via: RouterOsProtocol,
484
+ endpoint: AnyServiceEndpoint,
485
+ ): QuickchrConnection {
486
+ if (!isEndpointAvailable(endpoint)) {
487
+ throw unsupportedVia(
488
+ resolution,
489
+ via,
490
+ `the service is not available (${endpoint.unavailableReason})`,
491
+ { unavailableReason: endpoint.unavailableReason },
492
+ );
493
+ }
494
+
495
+ const source = {
496
+ kind: "provider" as const,
497
+ key: `quickchr:${resolution.name}`,
498
+ };
499
+ const hostForUrl = formatHostForUrl(endpoint.host);
500
+ let baseUrl: string;
501
+ let scheme: "http" | "https";
502
+ if (via === "ssh") {
503
+ scheme = "http";
504
+ baseUrl = `ssh://${hostForUrl}:${endpoint.port}`;
505
+ } else if (via === "native-api") {
506
+ scheme = endpoint.tls ? "https" : "http";
507
+ baseUrl = `${endpoint.tls ? "api-ssl" : "api"}://${hostForUrl}:${endpoint.port}`;
508
+ } else {
509
+ scheme = endpoint.tls ? "https" : "http";
510
+ baseUrl = `${scheme}://${hostForUrl}:${endpoint.port}/rest`;
511
+ }
512
+
513
+ const target: ResolvedTarget = {
514
+ input: resolution.name,
515
+ host: endpoint.host,
516
+ port: endpoint.port,
517
+ scheme,
518
+ tls: endpoint.tls,
519
+ baseUrl,
520
+ identity: resolution.name,
521
+ source,
522
+ hostSource: source,
523
+ portSource: source,
524
+ sources: { host: source, port: source },
525
+ };
526
+
527
+ // Trust-by-provenance: a TLS endpoint presents the CHR's self-signed cert
528
+ // and an SSH endpoint an ephemeral host key, and the descriptor carries no
529
+ // trust material — so the provider's voucher for its loopback forward is the
530
+ // trust anchor, surfaced as a warning (see QuickchrConnection.insecure).
531
+ const insecure = via === "ssh" || endpoint.tls;
532
+ const warnings = insecure
533
+ ? [
534
+ {
535
+ code: "transport/provider-trust",
536
+ message:
537
+ via === "ssh"
538
+ ? `quickchr machine "${resolution.name}": strict SSH host-key checking is relaxed for the ephemeral VM (loopback forward vouched by the provider).`
539
+ : `quickchr machine "${resolution.name}": the ${via} endpoint uses the VM's self-signed TLS certificate; peer verification is relaxed (loopback forward vouched by the provider).`,
540
+ },
541
+ ]
542
+ : [];
543
+
544
+ if (via === "ssh") {
545
+ // The contract guarantees `auth` on an available SSH endpoint; treat a
546
+ // malformed descriptor (missing auth / batchModes / a `private-key` batch
547
+ // mode with no key path) as batch-incapable rather than crashing on a
548
+ // field access or letting the SSH client fall back to ambient config.
549
+ const auth = endpoint.auth as SshEndpointAuth | undefined;
550
+ if (auth === undefined || !hasUsableSshAuth(auth)) {
551
+ throw unsupportedVia(
552
+ resolution,
553
+ via,
554
+ "the SSH endpoint has no usable batch-capable (non-interactive) auth mode, and centrs never password-prompts for a provider target",
555
+ { modes: auth?.modes ?? [], batchModes: auth?.batchModes ?? [] },
556
+ );
557
+ }
558
+ return {
559
+ target,
560
+ auth: {
561
+ username: auth.username,
562
+ usernameSource: { ...source, key: `${source.key}:username` },
563
+ password: "",
564
+ passwordProvided: false,
565
+ sshKey: auth.privateKeyPath,
566
+ sshKeySource:
567
+ auth.privateKeyPath !== undefined
568
+ ? { ...source, key: `${source.key}:ssh-key` }
569
+ : undefined,
570
+ },
571
+ insecure,
572
+ warnings,
573
+ };
574
+ }
575
+
576
+ const auth = endpoint.auth as
577
+ | { username: string; password?: string }
578
+ | undefined;
579
+ return {
580
+ target,
581
+ auth: {
582
+ username: auth?.username,
583
+ usernameSource:
584
+ auth?.username !== undefined
585
+ ? { ...source, key: `${source.key}:username` }
586
+ : undefined,
587
+ password: auth?.password ?? "",
588
+ passwordProvided: auth?.password !== undefined,
589
+ passwordSource:
590
+ auth?.password !== undefined
591
+ ? { ...source, key: `${source.key}:password` }
592
+ : undefined,
593
+ },
594
+ insecure,
595
+ warnings,
596
+ };
597
+ }