@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.
- package/CHANGELOG.md +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- 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
|
+
}
|
package/src/resolver/settings.ts
CHANGED
|
@@ -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 →
|
|
65
|
-
* `normalize` runs
|
|
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
|
|