@tikoci/centrs 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -2
- package/GLOSSARY.txt +87 -0
- package/README.md +25 -9
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +177 -0
- package/commands/api/examples.md +428 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +48 -7
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +37 -51
- package/commands/retrieve/examples.md +40 -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 +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +40 -15
- package/docs/MATRIX.md +69 -142
- package/docs/errors/README.md +2 -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/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -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/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +9 -9
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +1627 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +660 -0
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +75 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +29 -0
- package/src/core/error-catalog.ts +69 -0
- package/src/core/fanout.ts +301 -0
- package/src/core/inspect.ts +170 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +134 -70
- package/src/index.ts +166 -9
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +357 -9
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/index.ts +11 -5
- package/src/protocols/native-api.ts +223 -30
- 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 +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +232 -268
- package/src/retrieve.ts +69 -94
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
- package/commands/stream/README.md +0 -70
|
@@ -0,0 +1,304 @@
|
|
|
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 expandCdbSelection}. */
|
|
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
|
+
|
|
75
|
+
export interface CdbSelectionResolveInput {
|
|
76
|
+
cdbFile?: string;
|
|
77
|
+
cdbPassword?: string;
|
|
78
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
79
|
+
allowAdhoc: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One resolved selection member: a CDB record or an ad-hoc literal target. */
|
|
83
|
+
export type CdbSelectionMember =
|
|
84
|
+
| { kind: "cdb"; resolution: CdbResolution; recordIndex: number }
|
|
85
|
+
| { kind: "literal"; input: string };
|
|
86
|
+
|
|
87
|
+
export interface CdbSelectionExpansion {
|
|
88
|
+
/** Members ordered by record index; literals appended in positional order. */
|
|
89
|
+
targets: readonly CdbSelectionMember[];
|
|
90
|
+
warnings: readonly ResolverWarning[];
|
|
91
|
+
/** True when nothing matched (an empty/unknown selection is not an error). */
|
|
92
|
+
empty: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when a resolved target is the reserved credential-fallback record. */
|
|
96
|
+
export function isDefaultRecordTarget(target: string): boolean {
|
|
97
|
+
return target === DEFAULT_RECORD_TARGET;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ENV_CDB_FILE = "CENTRS_CDB_FILE";
|
|
101
|
+
const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Expand a {@link TargetSelection} into N resolved members. The CDB is loaded
|
|
105
|
+
* once; selectors and positionals union into a record-index set (plus literals),
|
|
106
|
+
* de-duped, ordered by record index. Empty/unknown selection → `empty: true`
|
|
107
|
+
* with a warning, never an error. A decrypt/parse failure throws (the fan-out's
|
|
108
|
+
* outer envelope reports it).
|
|
109
|
+
*
|
|
110
|
+
* When no CDB exists and none was requested explicitly, the expansion proceeds
|
|
111
|
+
* over an empty registry — so a pure-literal selection (`api r1 r2 …`) still
|
|
112
|
+
* fans out (mirroring single-target `resolveCdb`, which treats a `<router>` as a
|
|
113
|
+
* literal host when no CDB is present). An explicit `--cdb-file` that is missing
|
|
114
|
+
* still throws.
|
|
115
|
+
*/
|
|
116
|
+
export async function expandCdbSelection(
|
|
117
|
+
selection: TargetSelection,
|
|
118
|
+
input: CdbSelectionResolveInput,
|
|
119
|
+
env: Record<string, string | undefined>,
|
|
120
|
+
config: Record<string, string | undefined> = {},
|
|
121
|
+
): Promise<CdbSelectionExpansion> {
|
|
122
|
+
const settings = resolveDevicesSettings({
|
|
123
|
+
cdbFile: input.cdbFile,
|
|
124
|
+
cdbPassword: input.cdbPassword,
|
|
125
|
+
env,
|
|
126
|
+
config,
|
|
127
|
+
});
|
|
128
|
+
const explicitCdb =
|
|
129
|
+
input.cdbFile !== undefined ||
|
|
130
|
+
input.cdbPassword !== undefined ||
|
|
131
|
+
env[ENV_CDB_FILE] !== undefined ||
|
|
132
|
+
env[ENV_CDB_PASSWORD] !== undefined ||
|
|
133
|
+
config[ENV_CDB_FILE] !== undefined;
|
|
134
|
+
const emptyCdb = (): LoadedCdb => ({
|
|
135
|
+
entries: [],
|
|
136
|
+
warnings: [],
|
|
137
|
+
encrypted: false,
|
|
138
|
+
settings,
|
|
139
|
+
});
|
|
140
|
+
let cdb: LoadedCdb;
|
|
141
|
+
// Mirror `resolveCdb`: an absent, non-explicit default CDB is an empty registry,
|
|
142
|
+
// NOT an error and NOT a freshly created file (`loadCdb` would create one).
|
|
143
|
+
if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
|
|
144
|
+
cdb = emptyCdb();
|
|
145
|
+
} else {
|
|
146
|
+
try {
|
|
147
|
+
cdb = await loadCdb({
|
|
148
|
+
cdbFile: input.cdbFile,
|
|
149
|
+
cdbPassword: input.cdbPassword,
|
|
150
|
+
env,
|
|
151
|
+
config,
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (
|
|
155
|
+
!explicitCdb &&
|
|
156
|
+
error instanceof CentrsError &&
|
|
157
|
+
error.code === "cdb/not-found"
|
|
158
|
+
) {
|
|
159
|
+
cdb = emptyCdb();
|
|
160
|
+
} else {
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const warnings: ResolverWarning[] = cdb.warnings.map((warning) => ({
|
|
166
|
+
code: warning.code,
|
|
167
|
+
message: warning.message,
|
|
168
|
+
context: warning.context,
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
const groupSet = new Set(selection.groups);
|
|
172
|
+
const indices = new Set<number>();
|
|
173
|
+
|
|
174
|
+
for (let index = 0; index < cdb.entries.length; index += 1) {
|
|
175
|
+
const entry = cdb.entries[index];
|
|
176
|
+
if (!entry || entry.target === DEFAULT_RECORD_TARGET) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (selection.all) {
|
|
180
|
+
indices.add(index);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (groupSet.size > 0 && groupSet.has(entry.group)) {
|
|
184
|
+
indices.add(index);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
selection.where.length > 0 &&
|
|
189
|
+
matchesWhere(
|
|
190
|
+
entryFacts(entry.comment, entry.target, entry.group),
|
|
191
|
+
selection.where,
|
|
192
|
+
)
|
|
193
|
+
) {
|
|
194
|
+
indices.add(index);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Geo predicates are additional OR-union terms (like `--group`/`--where`):
|
|
198
|
+
// an entry matches when its GPS falls in `--near`'s radius OR `--bbox`.
|
|
199
|
+
// Geo-less entries carry no location, so they never match (not an error).
|
|
200
|
+
if (selection.near !== undefined || selection.bbox !== undefined) {
|
|
201
|
+
const loc = entryLocation(entry.comment);
|
|
202
|
+
if (
|
|
203
|
+
loc !== undefined &&
|
|
204
|
+
((selection.near !== undefined && matchesNear(loc, selection.near)) ||
|
|
205
|
+
(selection.bbox !== undefined && matchesBbox(loc, selection.bbox)))
|
|
206
|
+
) {
|
|
207
|
+
indices.add(index);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (selection.default) {
|
|
213
|
+
const defaultIndex = cdb.entries.findIndex(
|
|
214
|
+
(entry) => entry?.target === DEFAULT_RECORD_TARGET,
|
|
215
|
+
);
|
|
216
|
+
if (defaultIndex >= 0) {
|
|
217
|
+
indices.add(defaultIndex);
|
|
218
|
+
} else {
|
|
219
|
+
warnings.push({
|
|
220
|
+
code: "cdb/empty-selection",
|
|
221
|
+
message: "No `__default__` record exists to select with `--default`.",
|
|
222
|
+
context: { selector: "--default" },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const literals: CdbSelectionMember[] = [];
|
|
228
|
+
const seenLiteral = new Set<string>();
|
|
229
|
+
for (const positional of selection.positionals) {
|
|
230
|
+
try {
|
|
231
|
+
const shown = showDevice({ cdb, target: positional });
|
|
232
|
+
indices.add(shown.data.entry.cdbRecordIndex);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (
|
|
235
|
+
error instanceof CentrsError &&
|
|
236
|
+
error.code === "cdb/not-found-target"
|
|
237
|
+
) {
|
|
238
|
+
if (!input.allowAdhoc) {
|
|
239
|
+
throw new CentrsError({
|
|
240
|
+
code: "cdb/target-not-registered",
|
|
241
|
+
summary: `"${positional}" is not a registered CDB target.`,
|
|
242
|
+
remediation:
|
|
243
|
+
"Register the device with `centrs devices add`, or select registered targets via `--group` / `--where` / `--all`.",
|
|
244
|
+
context: { target: positional },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const key = positional.trim().toLowerCase();
|
|
248
|
+
if (!seenLiteral.has(key)) {
|
|
249
|
+
seenLiteral.add(key);
|
|
250
|
+
literals.push({ kind: "literal", input: positional });
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const cdbMembers: CdbSelectionMember[] = [...indices]
|
|
259
|
+
.sort((a, b) => a - b)
|
|
260
|
+
.map((index) => ({
|
|
261
|
+
kind: "cdb",
|
|
262
|
+
resolution: resolutionFromEntry(
|
|
263
|
+
cdb.entries[index] as NonNullable<(typeof cdb.entries)[number]>,
|
|
264
|
+
index,
|
|
265
|
+
),
|
|
266
|
+
recordIndex: index,
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
const targets = [...cdbMembers, ...literals];
|
|
270
|
+
if (targets.length === 0) {
|
|
271
|
+
const groupOnly =
|
|
272
|
+
groupSet.size > 0 &&
|
|
273
|
+
selection.positionals.length === 0 &&
|
|
274
|
+
!selection.all &&
|
|
275
|
+
!selection.default &&
|
|
276
|
+
selection.where.length === 0 &&
|
|
277
|
+
selection.near === undefined &&
|
|
278
|
+
selection.bbox === undefined;
|
|
279
|
+
warnings.push(
|
|
280
|
+
groupOnly
|
|
281
|
+
? {
|
|
282
|
+
code: "cdb/empty-group",
|
|
283
|
+
message: `No CDB entries matched group ${[...groupSet].map((g) => `"${g}"`).join(", ")}.`,
|
|
284
|
+
context: { groups: [...groupSet] },
|
|
285
|
+
}
|
|
286
|
+
: {
|
|
287
|
+
code: "cdb/empty-selection",
|
|
288
|
+
message: "The target selection matched no CDB records.",
|
|
289
|
+
context: {
|
|
290
|
+
groups: [...groupSet],
|
|
291
|
+
all: selection.all,
|
|
292
|
+
default: selection.default,
|
|
293
|
+
where: selection.where.length,
|
|
294
|
+
near: selection.near !== undefined,
|
|
295
|
+
bbox: selection.bbox !== undefined,
|
|
296
|
+
positionals: selection.positionals.length,
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
return { targets: [], warnings, empty: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { targets, warnings, empty: false };
|
|
304
|
+
}
|
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
|
|
package/src/resolver/target.ts
CHANGED
|
@@ -84,6 +84,7 @@ export function resolveTarget(
|
|
|
84
84
|
env: Record<string, string | undefined>,
|
|
85
85
|
via: RouterOsProtocol,
|
|
86
86
|
cdb?: CdbResolution,
|
|
87
|
+
config: Record<string, string | undefined> = {},
|
|
87
88
|
): ResolvedTarget {
|
|
88
89
|
const hostSetting = resolveStringSetting(
|
|
89
90
|
input.host,
|
|
@@ -91,6 +92,9 @@ export function resolveTarget(
|
|
|
91
92
|
ENV_HOST,
|
|
92
93
|
cdb?.target ?? input.targetInput,
|
|
93
94
|
"host",
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
config,
|
|
94
98
|
);
|
|
95
99
|
|
|
96
100
|
if (!hostSetting || hostSetting.value.trim().length === 0) {
|
|
@@ -137,6 +141,9 @@ export function resolveTarget(
|
|
|
137
141
|
ENV_HOST,
|
|
138
142
|
undefined,
|
|
139
143
|
"host",
|
|
144
|
+
undefined,
|
|
145
|
+
undefined,
|
|
146
|
+
config,
|
|
140
147
|
);
|
|
141
148
|
const macPortSetting = resolveOptionalIntegerSetting(
|
|
142
149
|
input.port,
|
|
@@ -144,6 +151,7 @@ export function resolveTarget(
|
|
|
144
151
|
ENV_PORT,
|
|
145
152
|
"port",
|
|
146
153
|
cdb?.overrides.port,
|
|
154
|
+
config,
|
|
147
155
|
);
|
|
148
156
|
// The device identity is the MAC (positional or CDB), NOT --host/CENTRS_HOST,
|
|
149
157
|
// which are only the UDP delivery endpoint. Attribute meta.target.source to
|
|
@@ -217,6 +225,7 @@ export function resolveTarget(
|
|
|
217
225
|
ENV_PORT,
|
|
218
226
|
"port",
|
|
219
227
|
cdb?.overrides.port,
|
|
228
|
+
config,
|
|
220
229
|
);
|
|
221
230
|
const scheme = parsedUrl.protocol === "https:" ? "https" : "http";
|
|
222
231
|
|
|
@@ -320,7 +329,14 @@ export function resolveAuth(
|
|
|
320
329
|
credentials: { username?: string; password?: string; sshKey?: string },
|
|
321
330
|
env: Record<string, string | undefined>,
|
|
322
331
|
cdb?: CdbResolution,
|
|
332
|
+
config: Record<string, string | undefined> = {},
|
|
323
333
|
): ResolvedAuth {
|
|
334
|
+
// `CENTRS_USERNAME`/`CENTRS_PASSWORD` are deliberately NOT threaded through
|
|
335
|
+
// the `config` tier: `settings` refuses to write either (constitution: the
|
|
336
|
+
// `__default__` CDB record is the credential store), and the config-file
|
|
337
|
+
// loader itself does not surface them even when hand-added to the file —
|
|
338
|
+
// a stronger defense than read-time redaction alone, since a hand-edited
|
|
339
|
+
// credential line in `centrs.env` then can't function as a live source.
|
|
324
340
|
const username = resolveStringSetting(
|
|
325
341
|
credentials.username,
|
|
326
342
|
env,
|
|
@@ -328,8 +344,9 @@ export function resolveAuth(
|
|
|
328
344
|
undefined,
|
|
329
345
|
"username",
|
|
330
346
|
);
|
|
331
|
-
// SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv →
|
|
332
|
-
// Path only; the SFTP/SSH transport reads the file, centrs
|
|
347
|
+
// SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv →
|
|
348
|
+
// config → unset. Path only; the SFTP/SSH transport reads the file, centrs
|
|
349
|
+
// never stores material.
|
|
333
350
|
const sshKey = resolveStringSetting(
|
|
334
351
|
credentials.sshKey,
|
|
335
352
|
env,
|
|
@@ -338,6 +355,7 @@ export function resolveAuth(
|
|
|
338
355
|
"ssh-key",
|
|
339
356
|
undefined,
|
|
340
357
|
cdb?.overrides.sshKey,
|
|
358
|
+
config,
|
|
341
359
|
);
|
|
342
360
|
const password = resolveStringSetting(
|
|
343
361
|
credentials.password,
|