@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,391 @@
1
+ /**
2
+ * Target-selection grammar (the full fan-out selector).
3
+ *
4
+ * Generalizes `expandCdbGroup` (a single `--group`) into the constitution's
5
+ * one grammar for every non-terminal command: `<router>` positionals (one or
6
+ * more), repeatable `--group`, `--all` (every CDB record), `--default` (the
7
+ * reserved `__default__` record), and repeatable `--where <attr>=<value>` (a
8
+ * device-class selector over CDB facts + core fields). The union is de-duped by
9
+ * CDB record index (literals by normalized input) and ordered by record index so
10
+ * repeated runs diff cleanly (see `docs/CONSTITUTION.md`, Target selection).
11
+ *
12
+ * Boundaries this module is careful about:
13
+ * - The CDB is loaded + decrypted ONCE for the whole expansion.
14
+ * - A positional is matched against the already-loaded entries with
15
+ * {@link showDevice} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
16
+ * re-loads the CDB and, when a `__default__` record exists, returns a
17
+ * synthetic resolution for ANY unmatched target, which would collide two
18
+ * distinct ad-hoc literals on the default record's index. A non-matching
19
+ * positional stays a literal (de-duped by input); default credentials are
20
+ * applied later by the per-target resolver, not here.
21
+ * - `--all` / `--group` / `--where` never include the `__default__` record (it
22
+ * is a credential-fallback record, not a fleet member). Only `--default` (or
23
+ * an explicit `__default__` positional) selects it; the per-target resolver
24
+ * guards it from being dialed as the literal hostname `"__default__"`.
25
+ * - `allowAdhoc` is the surface policy: CLI passes `true` (literal positionals
26
+ * allowed); MCP passes `false`, so an unregistered literal is rejected with
27
+ * `cdb/target-not-registered` and selection cannot widen the CDB allowlist.
28
+ */
29
+
30
+ import {
31
+ type LoadedCdb,
32
+ loadCdb,
33
+ resolveDevicesSettings,
34
+ showDevice,
35
+ } from "../devices.ts";
36
+ import { CentrsError } from "../errors.ts";
37
+ import {
38
+ type CdbResolution,
39
+ DEFAULT_RECORD_TARGET,
40
+ type ResolverWarning,
41
+ resolutionFromEntry,
42
+ } from "./cdb.ts";
43
+ import { entryFacts, entryLocation, matchesWhere } from "./facts.ts";
44
+ import {
45
+ type BboxPredicate,
46
+ matchesBbox,
47
+ matchesNear,
48
+ type NearPredicate,
49
+ } from "./geo.ts";
50
+
51
+ /** One `--where attr=value` device-class clause (exact-match, AND-combined). */
52
+ export interface SelectionWhereClause {
53
+ key: string;
54
+ value: string;
55
+ }
56
+
57
+ /** The parsed target selection a command hands to {@link expandSelection}. */
58
+ export interface TargetSelection {
59
+ /** Literal/lookup positionals (NOT operation args — the caller splits those). */
60
+ positionals: readonly string[];
61
+ /** Repeatable `--group <name>`. */
62
+ groups: readonly string[];
63
+ /** `--all`: every CDB record except `__default__`. */
64
+ all: boolean;
65
+ /** `--default`: the reserved `__default__` record. */
66
+ default: boolean;
67
+ /** Repeatable `--where attr=value`, AND-combined. */
68
+ where: readonly SelectionWhereClause[];
69
+ /** `--near <lat>,<lon>,<radius>`: a geo union predicate (device GPS within radius). */
70
+ near?: NearPredicate;
71
+ /** `--bbox <south>,<west>,<north>,<east>`: a geo union predicate (device GPS in box). */
72
+ bbox?: BboxPredicate;
73
+ /**
74
+ * Repeatable `--quickchr <name>`: quickchr machine names (the named-live-
75
+ * provider, #134). Exclusive in v1 — a selection targets quickchr machines OR
76
+ * CDB/literals, never both; the CLI enforces the usage error before expansion
77
+ * and {@link expandSelection} re-asserts it.
78
+ */
79
+ quickchr?: readonly string[];
80
+ }
81
+
82
+ export interface SelectionResolveInput {
83
+ cdbFile?: string;
84
+ cdbPassword?: string;
85
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
86
+ allowAdhoc: boolean;
87
+ }
88
+
89
+ /**
90
+ * One resolved selection member: a CDB record or an ad-hoc literal target.
91
+ *
92
+ * This is the connection-fact origin ("Axis B") the fan-out callers consume. The
93
+ * selector-source (CDB) and literal arms carry a single endpoint materialized
94
+ * later by `./target.ts`. The `quickchr` arm is the named-live-provider (#134):
95
+ * it carries only the machine NAME here — the live descriptor is resolved in the
96
+ * per-member fan-out `resolve()` path (via `resolveQuickchrTarget`), so a
97
+ * stopped/unknown machine is an inner per-target failure, not an expansion
98
+ * error — see `docs/CONSTITUTION.md` → Resolution providers.
99
+ */
100
+ export type SelectionMember =
101
+ | { kind: "cdb"; resolution: CdbResolution; recordIndex: number }
102
+ | { kind: "literal"; input: string }
103
+ | { kind: "quickchr"; name: string };
104
+
105
+ export interface SelectionExpansion {
106
+ /** Members ordered by record index; literals appended in positional order. */
107
+ targets: readonly SelectionMember[];
108
+ warnings: readonly ResolverWarning[];
109
+ /** True when nothing matched (an empty/unknown selection is not an error). */
110
+ empty: boolean;
111
+ }
112
+
113
+ /** True when a resolved target is the reserved credential-fallback record. */
114
+ export function isDefaultRecordTarget(target: string): boolean {
115
+ return target === DEFAULT_RECORD_TARGET;
116
+ }
117
+
118
+ const ENV_CDB_FILE = "CENTRS_CDB_FILE";
119
+ const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
120
+
121
+ /**
122
+ * Expand a {@link TargetSelection} into N resolved members. The CDB is loaded
123
+ * once; selectors and positionals union into a record-index set (plus literals),
124
+ * de-duped, ordered by record index. Empty/unknown selection → `empty: true`
125
+ * with a warning, never an error. A decrypt/parse failure throws (the fan-out's
126
+ * outer envelope reports it).
127
+ *
128
+ * When no CDB exists and none was requested explicitly, the expansion proceeds
129
+ * over an empty registry — so a pure-literal selection (`api r1 r2 …`) still
130
+ * fans out (mirroring single-target `resolveCdb`, which treats a `<router>` as a
131
+ * literal host when no CDB is present). An explicit `--cdb-file` that is missing
132
+ * still throws.
133
+ */
134
+ export async function expandSelection(
135
+ selection: TargetSelection,
136
+ input: SelectionResolveInput,
137
+ env: Record<string, string | undefined>,
138
+ config: Record<string, string | undefined> = {},
139
+ ): Promise<SelectionExpansion> {
140
+ const quickchrNames = selection.quickchr ?? [];
141
+ if (quickchrNames.length > 0) {
142
+ return expandQuickchrSelection(selection, input, quickchrNames);
143
+ }
144
+ const settings = resolveDevicesSettings({
145
+ cdbFile: input.cdbFile,
146
+ cdbPassword: input.cdbPassword,
147
+ env,
148
+ config,
149
+ });
150
+ const explicitCdb =
151
+ input.cdbFile !== undefined ||
152
+ input.cdbPassword !== undefined ||
153
+ env[ENV_CDB_FILE] !== undefined ||
154
+ env[ENV_CDB_PASSWORD] !== undefined ||
155
+ config[ENV_CDB_FILE] !== undefined;
156
+ const emptyCdb = (): LoadedCdb => ({
157
+ entries: [],
158
+ warnings: [],
159
+ encrypted: false,
160
+ settings,
161
+ });
162
+ let cdb: LoadedCdb;
163
+ // Mirror `resolveCdb`: an absent, non-explicit default CDB is an empty registry,
164
+ // NOT an error and NOT a freshly created file (`loadCdb` would create one).
165
+ if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
166
+ cdb = emptyCdb();
167
+ } else {
168
+ try {
169
+ cdb = await loadCdb({
170
+ cdbFile: input.cdbFile,
171
+ cdbPassword: input.cdbPassword,
172
+ env,
173
+ config,
174
+ });
175
+ } catch (error) {
176
+ if (
177
+ !explicitCdb &&
178
+ error instanceof CentrsError &&
179
+ error.code === "cdb/not-found"
180
+ ) {
181
+ cdb = emptyCdb();
182
+ } else {
183
+ throw error;
184
+ }
185
+ }
186
+ }
187
+ const warnings: ResolverWarning[] = cdb.warnings.map((warning) => ({
188
+ code: warning.code,
189
+ message: warning.message,
190
+ context: warning.context,
191
+ }));
192
+
193
+ const groupSet = new Set(selection.groups);
194
+ const indices = new Set<number>();
195
+
196
+ for (let index = 0; index < cdb.entries.length; index += 1) {
197
+ const entry = cdb.entries[index];
198
+ if (!entry || entry.target === DEFAULT_RECORD_TARGET) {
199
+ continue;
200
+ }
201
+ if (selection.all) {
202
+ indices.add(index);
203
+ continue;
204
+ }
205
+ if (groupSet.size > 0 && groupSet.has(entry.group)) {
206
+ indices.add(index);
207
+ continue;
208
+ }
209
+ if (
210
+ selection.where.length > 0 &&
211
+ matchesWhere(
212
+ entryFacts(entry.comment, entry.target, entry.group),
213
+ selection.where,
214
+ )
215
+ ) {
216
+ indices.add(index);
217
+ continue;
218
+ }
219
+ // Geo predicates are additional OR-union terms (like `--group`/`--where`):
220
+ // an entry matches when its GPS falls in `--near`'s radius OR `--bbox`.
221
+ // Geo-less entries carry no location, so they never match (not an error).
222
+ if (selection.near !== undefined || selection.bbox !== undefined) {
223
+ const loc = entryLocation(entry.comment);
224
+ if (
225
+ loc !== undefined &&
226
+ ((selection.near !== undefined && matchesNear(loc, selection.near)) ||
227
+ (selection.bbox !== undefined && matchesBbox(loc, selection.bbox)))
228
+ ) {
229
+ indices.add(index);
230
+ }
231
+ }
232
+ }
233
+
234
+ if (selection.default) {
235
+ const defaultIndex = cdb.entries.findIndex(
236
+ (entry) => entry?.target === DEFAULT_RECORD_TARGET,
237
+ );
238
+ if (defaultIndex >= 0) {
239
+ indices.add(defaultIndex);
240
+ } else {
241
+ warnings.push({
242
+ code: "cdb/empty-selection",
243
+ message: "No `__default__` record exists to select with `--default`.",
244
+ context: { selector: "--default" },
245
+ });
246
+ }
247
+ }
248
+
249
+ const literals: SelectionMember[] = [];
250
+ const seenLiteral = new Set<string>();
251
+ for (const positional of selection.positionals) {
252
+ try {
253
+ const shown = showDevice({ cdb, target: positional });
254
+ indices.add(shown.data.entry.cdbRecordIndex);
255
+ } catch (error) {
256
+ if (
257
+ error instanceof CentrsError &&
258
+ error.code === "cdb/not-found-target"
259
+ ) {
260
+ if (!input.allowAdhoc) {
261
+ throw new CentrsError({
262
+ code: "cdb/target-not-registered",
263
+ summary: `"${positional}" is not a registered CDB target.`,
264
+ remediation:
265
+ "Register the device with `centrs devices add`, or select registered targets via `--group` / `--where` / `--all`.",
266
+ context: { target: positional },
267
+ });
268
+ }
269
+ const key = positional.trim().toLowerCase();
270
+ if (!seenLiteral.has(key)) {
271
+ seenLiteral.add(key);
272
+ literals.push({ kind: "literal", input: positional });
273
+ }
274
+ continue;
275
+ }
276
+ throw error;
277
+ }
278
+ }
279
+
280
+ const cdbMembers: SelectionMember[] = [...indices]
281
+ .sort((a, b) => a - b)
282
+ .map((index) => ({
283
+ kind: "cdb",
284
+ resolution: resolutionFromEntry(
285
+ cdb.entries[index] as NonNullable<(typeof cdb.entries)[number]>,
286
+ index,
287
+ ),
288
+ recordIndex: index,
289
+ }));
290
+
291
+ const targets = [...cdbMembers, ...literals];
292
+ if (targets.length === 0) {
293
+ const groupOnly =
294
+ groupSet.size > 0 &&
295
+ selection.positionals.length === 0 &&
296
+ !selection.all &&
297
+ !selection.default &&
298
+ selection.where.length === 0 &&
299
+ selection.near === undefined &&
300
+ selection.bbox === undefined;
301
+ warnings.push(
302
+ groupOnly
303
+ ? {
304
+ code: "cdb/empty-group",
305
+ message: `No CDB entries matched group ${[...groupSet].map((g) => `"${g}"`).join(", ")}.`,
306
+ context: { groups: [...groupSet] },
307
+ }
308
+ : {
309
+ code: "cdb/empty-selection",
310
+ message: "The target selection matched no CDB records.",
311
+ context: {
312
+ groups: [...groupSet],
313
+ all: selection.all,
314
+ default: selection.default,
315
+ where: selection.where.length,
316
+ near: selection.near !== undefined,
317
+ bbox: selection.bbox !== undefined,
318
+ positionals: selection.positionals.length,
319
+ },
320
+ },
321
+ );
322
+ return { targets: [], warnings, empty: true };
323
+ }
324
+
325
+ return { targets, warnings, empty: false };
326
+ }
327
+
328
+ /**
329
+ * Expand a quickchr-only selection. No CDB is loaded — a named-live-provider
330
+ * member never touches the registry, the `__default__` ladder, or the selector
331
+ * grammar. Members carry only the machine name (de-duped by exact name, flag
332
+ * order preserved); the live descriptor resolves per member inside the fan-out.
333
+ *
334
+ * Two guards are load-bearing:
335
+ * - Exclusivity (v1): mixing `--quickchr` with selectors/positionals is a
336
+ * usage error. The CLI throws first with flag-level wording; this re-assert
337
+ * protects library/MCP callers that build a {@link TargetSelection} directly.
338
+ * - `allowAdhoc: false` (the MCP surface) rejects quickchr members entirely:
339
+ * the CDB is the MCP allowlist, and a live provider must not widen it (#134
340
+ * non-goal).
341
+ */
342
+ function expandQuickchrSelection(
343
+ selection: TargetSelection,
344
+ input: SelectionResolveInput,
345
+ names: readonly string[],
346
+ ): SelectionExpansion {
347
+ if (!input.allowAdhoc) {
348
+ throw new CentrsError({
349
+ code: "cdb/target-not-registered",
350
+ summary:
351
+ "quickchr targets are not available on this surface; the CDB is the allowlist.",
352
+ remediation:
353
+ "Register the device with `centrs devices add` and select it by identity, or use the CLI for `--quickchr` targets.",
354
+ context: { quickchr: [...names] },
355
+ });
356
+ }
357
+ if (
358
+ selection.positionals.length > 0 ||
359
+ selection.groups.length > 0 ||
360
+ selection.all ||
361
+ selection.default ||
362
+ selection.where.length > 0 ||
363
+ selection.near !== undefined ||
364
+ selection.bbox !== undefined
365
+ ) {
366
+ throw new CentrsError({
367
+ code: "usage/conflicting-flags",
368
+ summary:
369
+ "`--quickchr` is exclusive: a command targets quickchr machines or CDB/literal targets, not both.",
370
+ remediation:
371
+ "Drop the positional/selector (or the `--quickchr` flag) so the selection has one target source.",
372
+ context: {
373
+ quickchr: [...names],
374
+ positionals: selection.positionals.length,
375
+ groups: selection.groups.length,
376
+ all: selection.all,
377
+ default: selection.default,
378
+ where: selection.where.length,
379
+ },
380
+ });
381
+ }
382
+ const seen = new Set<string>();
383
+ const targets: SelectionMember[] = [];
384
+ for (const name of names) {
385
+ if (!seen.has(name)) {
386
+ seen.add(name);
387
+ targets.push({ kind: "quickchr", name });
388
+ }
389
+ }
390
+ return { targets, warnings: [], empty: false };
391
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The neutral per-service endpoint shape — the connection-fact origin ("Axis B")
3
+ * for a resolved target, decoupled from *how* the member was selected ("Axis A").
4
+ *
5
+ * This is the load-bearing scaffold for the resolution-provider refactor
6
+ * (`docs/CONSTITUTION.md` → Resolution providers; centrs #174). Two provider
7
+ * kinds feed it:
8
+ *
9
+ * - **Selector-source** — participates in the `--group`/`--where`/`--all`/
10
+ * `--near`/`--bbox` selector grammar. **CDB** today; **TikTOML** next (#137).
11
+ * CDB is the *degenerate single-endpoint* case: one record yields one
12
+ * host/port/auth, materialized lazily by `./target.ts` once `--via` is known,
13
+ * so a CDB member never needs a populated {@link ServiceEndpointMap}.
14
+ * - **Named-live-provider** — addressed only by an explicit named flag, never
15
+ * matched by selectors, fanned out by repeating the flag; owns an ephemeral
16
+ * endpoint so it also bypasses the settings/`__default__` ladder and conflicts
17
+ * with `--host`/`--port`/`--username`/`--password`/`--ssh-key`. **quickchr**
18
+ * (`--quickchr <name>`, #134). A live provider resolves a member into a
19
+ * *populated* per-service map, because it genuinely exposes different
20
+ * host/port/auth per transport (e.g. a SLiRP-forwarded loopback port per
21
+ * service). Flattening that to a single host/port is the exact refactor #174
22
+ * exists to prevent — TikTOML's `services` block is per-service too.
23
+ *
24
+ * The shape here deliberately mirrors quickchr's published descriptor contract
25
+ * (`tikoci/quickchr` `docs/centrs-interface.md`, `ServiceEndpoint`/
26
+ * `SshServiceEndpoint`), which states that centrs reuses *this same internal
27
+ * type* for TikTOML resolution. It is provider-neutral: `source.provider` is a
28
+ * free string, not a quickchr literal. Mapping a resolved endpoint's auth onto
29
+ * centrs' {@link ../resolver/target.ts | ResolvedAuth} (notably
30
+ * `privateKeyPath` → `sshKey`) is the provider adapter's job, not this type's.
31
+ *
32
+ * Phase 1 (this file) defines the contract only; the quickchr provider adapter
33
+ * that *produces* a map, and the `--via` consumer that *reads* `map[via]`, land
34
+ * with the `--quickchr` plumbing (#134 Phase 2+).
35
+ */
36
+
37
+ import type { RouterOsProtocol } from "../protocols/index.ts";
38
+
39
+ /** Provenance for a resolved endpoint — which provider vouched for it. */
40
+ export interface ServiceEndpointSource {
41
+ /** Provider identity, e.g. `"quickchr"` or `"tiktoml"`. Neutral by design. */
42
+ provider: string;
43
+ /** Provider-internal forward name backing the service (e.g. `"api-ssl"`), for provenance. */
44
+ portMappingName?: string;
45
+ }
46
+
47
+ /** REST/native-api auth. RouterOS shares one credential across REST and native. */
48
+ export interface ServiceEndpointAuth {
49
+ username: string;
50
+ password?: string;
51
+ /** Pre-encoded HTTP Basic value, when the provider supplies one directly. */
52
+ basic?: string;
53
+ /** Pre-formed auth header value, when the provider supplies one directly. */
54
+ header?: string;
55
+ }
56
+
57
+ /** SSH auth. Same credential vocabulary `+ key path`, plus batch-mode capability. */
58
+ export interface SshEndpointAuth {
59
+ username: string;
60
+ /**
61
+ * Path to a usable private key. Populated only when the provider has actually
62
+ * verified a batch (non-interactive) login with it — maps to
63
+ * {@link ../resolver/target.ts | ResolvedAuth.sshKey} in the provider adapter.
64
+ */
65
+ privateKeyPath?: string;
66
+ /** Every auth mode the endpoint accepts. */
67
+ modes: Array<"private-key" | "agent-or-config" | "password">;
68
+ /**
69
+ * The batch-capable subset. This is the gate centrs enforces for `--via ssh`
70
+ * and `transfer --via sftp`: an empty list means no non-interactive handoff is
71
+ * possible, so centrs must emit a typed unsupported-capability error rather
72
+ * than prompt for a password or silently fall back (#134 Phase 4).
73
+ */
74
+ batchModes: Array<"private-key" | "agent-or-config">;
75
+ passwordAvailable?: boolean;
76
+ }
77
+
78
+ /** Fields shared by every available endpoint regardless of auth flavor. */
79
+ interface AvailableEndpointBase {
80
+ available: true;
81
+ /** Hostname/IP to dial — NOT a port. `"127.0.0.1"` for a SLiRP-forwarded service. */
82
+ host: string;
83
+ port: number;
84
+ guestPort?: number;
85
+ transport: "tcp" | "udp";
86
+ /** True for TLS-backed endpoints (https / api-ssl); SSH is always `false` (own transport security). */
87
+ tls: boolean;
88
+ url?: string;
89
+ source?: ServiceEndpointSource;
90
+ }
91
+
92
+ /** An endpoint the provider could not vouch for; a consumer MUST gate on `available` before dialing. */
93
+ export interface UnavailableEndpoint {
94
+ available: false;
95
+ unavailableReason: string;
96
+ /** Best-effort echoes only — never dial these without a re-resolve. */
97
+ host?: string;
98
+ port?: number;
99
+ guestPort?: number;
100
+ transport?: "tcp" | "udp";
101
+ tls?: boolean;
102
+ url?: string;
103
+ source?: ServiceEndpointSource;
104
+ }
105
+
106
+ /** One per-service endpoint (rest-api / native-api). */
107
+ export type ServiceEndpoint =
108
+ | (AvailableEndpointBase & { auth?: ServiceEndpointAuth })
109
+ | UnavailableEndpoint;
110
+
111
+ /** The SSH endpoint — same union, with SSH-specific auth substituted. */
112
+ export type SshServiceEndpoint =
113
+ | (AvailableEndpointBase & { auth: SshEndpointAuth })
114
+ | UnavailableEndpoint;
115
+
116
+ /** Any resolved endpoint, whatever the service. */
117
+ export type AnyServiceEndpoint = ServiceEndpoint | SshServiceEndpoint;
118
+
119
+ /**
120
+ * The endpoint type for a given `--via` id. `ssh` carries the SSH-specific
121
+ * {@link SshServiceEndpoint} (its available form guarantees `auth: SshEndpointAuth`
122
+ * with `batchModes`, the gate Phase 4 enforces for `--via ssh`/`transfer --via
123
+ * sftp`); every other transport uses the shared credential {@link ServiceEndpoint}.
124
+ */
125
+ export type ServiceEndpointFor<K extends RouterOsProtocol> = K extends "ssh"
126
+ ? SshServiceEndpoint
127
+ : ServiceEndpoint;
128
+
129
+ /**
130
+ * A member's per-service connection facts, keyed by centrs `--via` id.
131
+ *
132
+ * Partial by design: a provider may cover only a subset of the eight protocols
133
+ * (quickchr v1 covers `rest-api`/`native-api`/`ssh`). A `--via` whose key is
134
+ * absent is a typed unsupported-provider error at consume time, not a silent
135
+ * fallback. CDB never populates this map (its single endpoint is materialized by
136
+ * `./target.ts`); only named-live-providers do.
137
+ *
138
+ * Keyed by service so per-`--via` precision survives: `map.ssh` is a
139
+ * {@link SshServiceEndpoint}, not a widened `AnyServiceEndpoint`, so a consumer
140
+ * reading `map.ssh` keeps the SSH `auth`/`batchModes` guarantee without a cast.
141
+ */
142
+ export type ServiceEndpointMap = {
143
+ [K in RouterOsProtocol]?: ServiceEndpointFor<K>;
144
+ };
145
+
146
+ /**
147
+ * Narrow an endpoint to its available form (the only form safe to dial). Generic
148
+ * so the caller's precise union is preserved: an {@link SshServiceEndpoint} narrows
149
+ * to its available arm (`auth: SshEndpointAuth` required), not the widened base.
150
+ */
151
+ export function isEndpointAvailable<T extends AnyServiceEndpoint>(
152
+ endpoint: T,
153
+ ): endpoint is Extract<T, { available: true }> {
154
+ return endpoint.available;
155
+ }
@@ -48,6 +48,23 @@ export interface ResolvedSetting<T> {
48
48
  /** A pre-coerced comment-kv override layer (sits below env, above config). */
49
49
  export type CommentKvLayer<T> = ResolvedSetting<T> | undefined;
50
50
 
51
+ /**
52
+ * Env keys that must never flow through the `config` (`centrs.env`) tier,
53
+ * even if a user hand-adds them to the file — credential-shaped or
54
+ * self-referential keys. Kept here (not in `src/settings.ts`, which owns the
55
+ * human-facing refused-key registry with its remediation text) so
56
+ * `resolver/config-file.ts` can filter on it without a resolver → top-level
57
+ * command import cycle; `test/unit/settings-registry.test.ts` asserts this
58
+ * list stays in sync with `settingsRefusedKeys`' `envKey` values.
59
+ */
60
+ export const REFUSED_CONFIG_ENV_KEYS: readonly string[] = [
61
+ "CENTRS_PASSWORD",
62
+ "CENTRS_USERNAME",
63
+ "CENTRS_CDB_PASSWORD",
64
+ "CENTRS_SKIP_ENV_FILE",
65
+ "CENTRS_RUN_FAST_INTEGRATION",
66
+ ];
67
+
51
68
  export function toCoreSource(source: ResolverSettingSource): CoreSettingSource {
52
69
  switch (source.kind) {
53
70
  case "explicit":
@@ -61,9 +78,9 @@ export function toCoreSource(source: ResolverSettingSource): CoreSettingSource {
61
78
  /**
62
79
  * Resolve a string-valued setting across the precedence ladder.
63
80
  *
64
- * Order checked (highest first): explicit/cli → env → comment-kv → default.
65
- * `normalize` runs only against explicit/env/default raw strings; the
66
- * comment-kv layer is already coerced to `T`.
81
+ * Order checked (highest first): explicit/cli → env → comment-kv → config →
82
+ * default. `normalize` runs against explicit/env/config/default raw strings;
83
+ * the comment-kv layer is already coerced to `T`.
67
84
  */
68
85
  export function resolveStringSetting<T = string>(
69
86
  explicit: string | undefined,
@@ -73,6 +90,7 @@ export function resolveStringSetting<T = string>(
73
90
  key: string,
74
91
  normalize?: (value: string) => T,
75
92
  commentKv?: CommentKvLayer<T>,
93
+ config?: Record<string, string | undefined>,
76
94
  ): ResolvedSetting<T> | undefined {
77
95
  if (explicit !== undefined) {
78
96
  return {
@@ -99,6 +117,14 @@ export function resolveStringSetting<T = string>(
99
117
  return commentKv;
100
118
  }
101
119
 
120
+ const configValue = config?.[envName];
121
+ if (configValue !== undefined) {
122
+ return {
123
+ value: normalize ? normalize(configValue) : (configValue as T),
124
+ source: { kind: "config", key: envName },
125
+ };
126
+ }
127
+
102
128
  if (defaultValue !== undefined) {
103
129
  return {
104
130
  value: normalize ? normalize(defaultValue) : (defaultValue as T),
@@ -114,7 +140,7 @@ export function resolveStringSetting<T = string>(
114
140
 
115
141
  /**
116
142
  * Resolve a boolean-valued setting. Order: explicit → env → comment-kv →
117
- * default.
143
+ * config → default.
118
144
  */
119
145
  export function resolveBooleanSetting(
120
146
  explicit: boolean | undefined,
@@ -123,6 +149,7 @@ export function resolveBooleanSetting(
123
149
  defaultValue: boolean,
124
150
  key: string,
125
151
  commentKv?: CommentKvLayer<boolean>,
152
+ config?: Record<string, string | undefined>,
126
153
  ): ResolvedSetting<boolean> {
127
154
  if (explicit !== undefined) {
128
155
  return { value: explicit, source: { kind: "explicit", key } };
@@ -140,12 +167,20 @@ export function resolveBooleanSetting(
140
167
  return commentKv;
141
168
  }
142
169
 
170
+ const configValue = config?.[envName];
171
+ if (configValue !== undefined) {
172
+ return {
173
+ value: parseBoolean(configValue, envName),
174
+ source: { kind: "config", key: envName },
175
+ };
176
+ }
177
+
143
178
  return { value: defaultValue, source: { kind: "default", key } };
144
179
  }
145
180
 
146
181
  /**
147
182
  * Resolve an optional positive-integer setting (no built-in default). Order:
148
- * explicit → env → comment-kv → unset.
183
+ * explicit → env → comment-kv → config → unset.
149
184
  */
150
185
  export function resolveOptionalIntegerSetting(
151
186
  explicit: number | undefined,
@@ -153,6 +188,7 @@ export function resolveOptionalIntegerSetting(
153
188
  envName: string,
154
189
  key: string,
155
190
  commentKv?: CommentKvLayer<number>,
191
+ config?: Record<string, string | undefined>,
156
192
  ): ResolvedSetting<number> | undefined {
157
193
  if (explicit !== undefined) {
158
194
  if (!Number.isInteger(explicit) || explicit <= 0) {
@@ -182,6 +218,19 @@ export function resolveOptionalIntegerSetting(
182
218
  return commentKv;
183
219
  }
184
220
 
221
+ const configValue = config?.[envName];
222
+ if (configValue !== undefined) {
223
+ const parsed = Number.parseInt(configValue, 10);
224
+ if (!Number.isInteger(parsed) || parsed <= 0) {
225
+ throw new CentrsError({
226
+ code: "settings/invalid-integer",
227
+ summary: `${envName} must be a positive integer. Received: ${configValue}`,
228
+ remediation: `Set ${envName} to a positive integer value in centrs.env.`,
229
+ });
230
+ }
231
+ return { value: parsed, source: { kind: "config", key: envName } };
232
+ }
233
+
185
234
  return undefined;
186
235
  }
187
236