@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
package/src/protocols/adapter.ts
CHANGED
|
@@ -610,23 +610,39 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
610
610
|
async execute(
|
|
611
611
|
request: ProtocolExecuteRequest,
|
|
612
612
|
): Promise<ProtocolExecuteResult> {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
) {
|
|
613
|
+
if (request.script !== undefined) {
|
|
614
|
+
return normalizeNativeExecute(await this.executeScript(request.script));
|
|
615
|
+
}
|
|
616
|
+
const command: NativeApiCommand = {
|
|
617
|
+
command: `${request.path.replace(/\/$/, "")}/${request.command}`,
|
|
618
|
+
};
|
|
619
|
+
if (request.attributes && Object.keys(request.attributes).length > 0) {
|
|
621
620
|
command.attributes = request.attributes;
|
|
622
621
|
}
|
|
623
|
-
if (
|
|
622
|
+
if (request.queries && request.queries.length > 0) {
|
|
624
623
|
command.queries = request.queries;
|
|
625
624
|
}
|
|
626
625
|
const replies = await this.talk(command);
|
|
627
626
|
return { records: repliesToRecords(replies) };
|
|
628
627
|
}
|
|
629
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Run a script through native `/execute` with `as-string`, the sole shared
|
|
631
|
+
* path for both the CLI-shaped `execute` command and the `api` run+script
|
|
632
|
+
* verb: without `as-string` the native API schedules a background job and
|
|
633
|
+
* returns only its id rather than the captured output (CHR-grounded —
|
|
634
|
+
* mirrors REST `/rest/execute`).
|
|
635
|
+
*/
|
|
636
|
+
private async executeScript(
|
|
637
|
+
script: string,
|
|
638
|
+
): Promise<Record<string, string>[]> {
|
|
639
|
+
const replies = await this.talk({
|
|
640
|
+
command: "/execute",
|
|
641
|
+
attributes: { script, "as-string": "" },
|
|
642
|
+
});
|
|
643
|
+
return repliesToRecords(replies);
|
|
644
|
+
}
|
|
645
|
+
|
|
630
646
|
async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
|
|
631
647
|
const base = request.path.replace(/\/$/, "");
|
|
632
648
|
switch (request.verb) {
|
|
@@ -682,15 +698,7 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
682
698
|
}
|
|
683
699
|
case "run": {
|
|
684
700
|
if (request.script !== undefined) {
|
|
685
|
-
|
|
686
|
-
// output; without it the native API schedules a job and returns its id
|
|
687
|
-
// (CHR-grounded — mirrors REST `/rest/execute`).
|
|
688
|
-
const records = repliesToRecords(
|
|
689
|
-
await this.talk({
|
|
690
|
-
command: "/execute",
|
|
691
|
-
attributes: { script: request.script, "as-string": "" },
|
|
692
|
-
}),
|
|
693
|
-
);
|
|
701
|
+
const records = await this.executeScript(request.script);
|
|
694
702
|
return { data: restStyleRunData(records) };
|
|
695
703
|
}
|
|
696
704
|
const command: NativeApiCommand = { command: base };
|
|
@@ -1089,6 +1097,18 @@ function exhaustiveApiVerb(verb: never): never {
|
|
|
1089
1097
|
throw new Error(`Unhandled api verb: ${String(verb)}`);
|
|
1090
1098
|
}
|
|
1091
1099
|
|
|
1100
|
+
/** Re-map an `as-string` native `/execute` reply into `ProtocolExecuteResult`,
|
|
1101
|
+
* mirroring `normalizeRestExecute`'s `ret`-vs-records split. */
|
|
1102
|
+
function normalizeNativeExecute(
|
|
1103
|
+
records: readonly Record<string, string>[],
|
|
1104
|
+
): ProtocolExecuteResult {
|
|
1105
|
+
const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
|
|
1106
|
+
if (typeof ret === "string") {
|
|
1107
|
+
return { records: [], ret };
|
|
1108
|
+
}
|
|
1109
|
+
return { records: [...records] };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1092
1112
|
function normalizeRestExecute(data: unknown): ProtocolExecuteResult {
|
|
1093
1113
|
if (Array.isArray(data)) {
|
|
1094
1114
|
return { records: data.filter(isPlainObject) as Record<string, string>[] };
|
package/src/protocols/index.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* Status per protocol is the implementation reality (`implemented` flag).
|
|
8
8
|
* Per-cell status across commands is in `docs/MATRIX.md`. Selection rules
|
|
9
9
|
* are in `docs/CONSTITUTION.md` (protocol selection).
|
|
10
|
+
*
|
|
11
|
+
* Contract with MATRIX (asserted by `test/unit/protocol-registry.test.ts`):
|
|
12
|
+
* every `CHR-passed` cell in the `docs/MATRIX.md` grid implies its protocol
|
|
13
|
+
* here is `implemented: true` and lists the matching capability. MATRIX stays
|
|
14
|
+
* the only status surface; this registry must never under-claim against it.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
export type ProtocolCapability =
|
|
@@ -35,7 +40,7 @@ export const protocolPlans = [
|
|
|
35
40
|
},
|
|
36
41
|
{
|
|
37
42
|
id: "native-api",
|
|
38
|
-
capabilities: ["retrieve", "execute", "proxy"],
|
|
43
|
+
capabilities: ["retrieve", "execute", "transfer", "proxy"],
|
|
39
44
|
notes:
|
|
40
45
|
"Binary API (TCP 8728 / TLS 8729). Transport base implemented in native-api.ts (word/sentence codec, login, tagged talk); command wiring tracked in docs/MATRIX.md.",
|
|
41
46
|
implemented: true,
|
|
@@ -44,8 +49,8 @@ export const protocolPlans = [
|
|
|
44
49
|
id: "ssh",
|
|
45
50
|
capabilities: ["execute", "terminal", "transfer"],
|
|
46
51
|
notes:
|
|
47
|
-
"Reuses host ssh client.
|
|
48
|
-
implemented:
|
|
52
|
+
"Reuses host ssh client. execute/terminal/transfer all CHR-passed (docs/MATRIX.md); facts in ssh.ts, sftp.ts, terminal.ts.",
|
|
53
|
+
implemented: true,
|
|
49
54
|
},
|
|
50
55
|
{
|
|
51
56
|
id: "snmp",
|
|
@@ -56,8 +61,9 @@ export const protocolPlans = [
|
|
|
56
61
|
{
|
|
57
62
|
id: "mndp",
|
|
58
63
|
capabilities: ["discover"],
|
|
59
|
-
notes:
|
|
60
|
-
|
|
64
|
+
notes:
|
|
65
|
+
"Passive hint source only; not authoritative inventory. discover CHR-passed (docs/MATRIX.md); facts in src/data/mndp.ts.",
|
|
66
|
+
implemented: true,
|
|
61
67
|
},
|
|
62
68
|
{
|
|
63
69
|
id: "mac-telnet",
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
* scraped for: `(evl …)` = ok, `syntax error (line …)` = malformed, and
|
|
40
40
|
* `bad parameter <name> (line …)` = unknown attribute. A single console
|
|
41
41
|
* `:parse` therefore covers both the syntax and the unknown-attribute
|
|
42
|
-
* (
|
|
42
|
+
* (name-level) gate — no `/console/inspect` table parsing needed.
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { parseRouterOsPosition } from "../core/routeros-errors.ts";
|
|
46
|
+
import { routerOsStringLiteral } from "../core/routeros-string.ts";
|
|
46
47
|
import { CentrsError } from "../errors.ts";
|
|
47
48
|
import {
|
|
48
49
|
type MacAddress,
|
|
@@ -323,8 +324,11 @@ export class MacTelnetConsole {
|
|
|
323
324
|
* Validate a command without running it, using a single console `:parse`.
|
|
324
325
|
* Throws `validation/syntax` for malformed CLI and
|
|
325
326
|
* `validation/unknown-attribute` for a rejected parameter; returns the parsed
|
|
326
|
-
* form on success. One gate covers both because the console
|
|
327
|
-
*
|
|
327
|
+
* form on success. One gate covers both because the console prints the
|
|
328
|
+
* `:parse` return value, which carries `bad parameter <name>` as well as the
|
|
329
|
+
* `(<%%` / `(line N column M)` syntax diagnostics — see
|
|
330
|
+
* {@link classifyParseResult} for the full grammar and why the matcher is
|
|
331
|
+
* anchored to the diagnostic shape rather than to bare words.
|
|
328
332
|
*/
|
|
329
333
|
async parseGate(cli: string): Promise<string> {
|
|
330
334
|
const { output } = await this.run(parseScriptFor(cli));
|
|
@@ -604,17 +608,61 @@ export function parseScriptFor(cli: string): string {
|
|
|
604
608
|
return `:put [:parse ${routerOsStringLiteral(cli)}]`;
|
|
605
609
|
}
|
|
606
610
|
|
|
611
|
+
/*
|
|
612
|
+
* The `:parse` diagnostic grammar, anchored to where RouterOS actually emits
|
|
613
|
+
* each shape. Anchoring is load-bearing, not cosmetic: an *accepted* return
|
|
614
|
+
* echoes the script body, so `:put "bad parameter x"` comes back as
|
|
615
|
+
* `(evl /putmessage=bad parameter x)`. An unanchored search reads that echoed
|
|
616
|
+
* content as a verdict and refuses to run a valid command (GH#230).
|
|
617
|
+
*
|
|
618
|
+
* Two shapes, both corpus-verified against lsp-routeros-ts
|
|
619
|
+
* `parseil_results.il_text` (2736 rows, RouterOS 7.20.8/7.22.1/7.23rc1):
|
|
620
|
+
*
|
|
621
|
+
* 1. WRAPPED — the diagnostic sits inside the returned value, marked by the
|
|
622
|
+
* `(evl ` / `(<%%` wrapper. It can appear mid-return, because a
|
|
623
|
+
* multi-statement script reports the offending statement in place. Every
|
|
624
|
+
* corpus `bad parameter` (8/8) follows `(evl `, and every `bad command
|
|
625
|
+
* name` (135/135) follows `(<%%`.
|
|
626
|
+
* 2. BARE — the diagnostic *is* the whole return, printed on its own line
|
|
627
|
+
* with RouterOS's position suffix. All 425 corpus rows of this shape are
|
|
628
|
+
* the entire return; none appear embedded. Requiring both line-start and
|
|
629
|
+
* the `(line N column M)` suffix is what separates a real verdict from
|
|
630
|
+
* echoed body text.
|
|
631
|
+
*/
|
|
632
|
+
const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
|
|
633
|
+
const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
|
|
634
|
+
const BARE_BAD_PARAMETER =
|
|
635
|
+
/^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
|
|
636
|
+
const BARE_SYNTAX =
|
|
637
|
+
/^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
638
|
+
|
|
607
639
|
/**
|
|
608
|
-
* Classify the
|
|
609
|
-
* `:parse`
|
|
610
|
-
*
|
|
611
|
-
*
|
|
640
|
+
* Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
|
|
641
|
+
* (see GH#230): `:parse` *never* throws — it returns a value whose text carries
|
|
642
|
+
* the verdict. For a bare menu path the accepted form is `(evl /canonical/path)`
|
|
643
|
+
* (abbreviations expanded); for script-shaped input the accepted return echoes
|
|
644
|
+
* the statement body and may contain arbitrary text — including text that looks
|
|
645
|
+
* like a diagnostic. Rejections are therefore matched only in the positions
|
|
646
|
+
* RouterOS emits them (see the grammar note above): wrapped as
|
|
647
|
+
* `(evl bad parameter <name> (line N column M) /path)` /
|
|
648
|
+
* `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
|
|
649
|
+
* line-initial as `expected … (line N column M)` / `syntax error (line N column
|
|
650
|
+
* M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
651
|
+
* (name-level) gate on every transport — REST and native API expose the same text
|
|
652
|
+
* in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
|
|
653
|
+
*
|
|
654
|
+
* Throws the matching `validation/*` error; returns on a clean parse.
|
|
612
655
|
*/
|
|
613
|
-
export function classifyParseResult(
|
|
614
|
-
|
|
656
|
+
export function classifyParseResult(
|
|
657
|
+
output: string,
|
|
658
|
+
cli: string,
|
|
659
|
+
via: string = "mac-telnet",
|
|
660
|
+
): void {
|
|
661
|
+
// The `:parse` rejection carries RouterOS's authoritative byte offset
|
|
615
662
|
// (`… (line N column M)`); surface it as the structured `error.position`.
|
|
616
663
|
const position = parseRouterOsPosition(output);
|
|
617
|
-
const unknown =
|
|
664
|
+
const unknown =
|
|
665
|
+
output.match(WRAPPED_BAD_PARAMETER) ?? output.match(BARE_BAD_PARAMETER);
|
|
618
666
|
if (unknown) {
|
|
619
667
|
throw new CentrsError({
|
|
620
668
|
code: "validation/unknown-attribute",
|
|
@@ -624,13 +672,13 @@ export function classifyParseResult(output: string, cli: string): void {
|
|
|
624
672
|
context: {
|
|
625
673
|
command: cli,
|
|
626
674
|
parameter: cleanToken(unknown[1] ?? ""),
|
|
627
|
-
validationSource:
|
|
675
|
+
validationSource: `:put [:parse ...] over ${via}`,
|
|
628
676
|
detail: output,
|
|
629
677
|
},
|
|
630
678
|
...(position ? { position } : {}),
|
|
631
679
|
});
|
|
632
680
|
}
|
|
633
|
-
if (
|
|
681
|
+
if (WRAPPED_BAD_COMMAND.test(output) || BARE_SYNTAX.test(output)) {
|
|
634
682
|
throw new CentrsError({
|
|
635
683
|
code: "validation/syntax",
|
|
636
684
|
summary: "RouterOS rejected the command syntax while parsing it.",
|
|
@@ -638,7 +686,7 @@ export function classifyParseResult(output: string, cli: string): void {
|
|
|
638
686
|
"Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
|
|
639
687
|
context: {
|
|
640
688
|
command: cli,
|
|
641
|
-
validationSource:
|
|
689
|
+
validationSource: `:put [:parse ...] over ${via}`,
|
|
642
690
|
detail: output,
|
|
643
691
|
},
|
|
644
692
|
...(position ? { position } : {}),
|
|
@@ -646,10 +694,6 @@ export function classifyParseResult(output: string, cli: string): void {
|
|
|
646
694
|
}
|
|
647
695
|
}
|
|
648
696
|
|
|
649
|
-
function routerOsStringLiteral(value: string): string {
|
|
650
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
697
|
function cleanToken(token: string): string {
|
|
654
698
|
return token.replace(/^["'`]+|["'`.,;:)]+$/g, "");
|
|
655
699
|
}
|
package/src/resolver/cdb.ts
CHANGED
|
@@ -153,6 +153,7 @@ function warningFromDevices(warning: DevicesWarning): ResolverWarning {
|
|
|
153
153
|
export async function resolveCdb(
|
|
154
154
|
input: CdbResolveInput,
|
|
155
155
|
env: Record<string, string | undefined>,
|
|
156
|
+
config: Record<string, string | undefined> = {},
|
|
156
157
|
): Promise<CdbResolution | undefined> {
|
|
157
158
|
if (!input.targetInput) {
|
|
158
159
|
return undefined;
|
|
@@ -162,12 +163,18 @@ export async function resolveCdb(
|
|
|
162
163
|
cdbFile: input.cdbFile,
|
|
163
164
|
cdbPassword: input.cdbPassword,
|
|
164
165
|
env,
|
|
166
|
+
config,
|
|
165
167
|
});
|
|
168
|
+
// A user-configured `centrs.env` default CDB path counts as explicit, same
|
|
169
|
+
// as an env var: it is an intentional choice, not the built-in fallback,
|
|
170
|
+
// so a missing file there should surface as a real error rather than
|
|
171
|
+
// silently degrading to "no CDB in play".
|
|
166
172
|
const explicitCdb =
|
|
167
173
|
input.cdbFile !== undefined ||
|
|
168
174
|
input.cdbPassword !== undefined ||
|
|
169
175
|
env[ENV_CDB_FILE] !== undefined ||
|
|
170
|
-
env[ENV_CDB_PASSWORD] !== undefined
|
|
176
|
+
env[ENV_CDB_PASSWORD] !== undefined ||
|
|
177
|
+
config[ENV_CDB_FILE] !== undefined;
|
|
171
178
|
if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
|
|
172
179
|
return undefined;
|
|
173
180
|
}
|
|
@@ -178,6 +185,7 @@ export async function resolveCdb(
|
|
|
178
185
|
cdbFile: input.cdbFile,
|
|
179
186
|
cdbPassword: input.cdbPassword,
|
|
180
187
|
env,
|
|
188
|
+
config,
|
|
181
189
|
});
|
|
182
190
|
} catch (error) {
|
|
183
191
|
if (
|
|
@@ -312,7 +320,7 @@ export async function expandCdbGroup(
|
|
|
312
320
|
return { targets, warnings, empty: false };
|
|
313
321
|
}
|
|
314
322
|
|
|
315
|
-
function resolutionFromEntry(
|
|
323
|
+
export function resolutionFromEntry(
|
|
316
324
|
entry: WinBoxCdbEntry,
|
|
317
325
|
recordIndex: number,
|
|
318
326
|
): CdbResolution {
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* token (which may have been quoted).
|
|
18
18
|
* - Free-form text outside well-formed `key=value` tokens is inert: no value,
|
|
19
19
|
* no warning.
|
|
20
|
-
* - Allowlisted keys populate `values
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* - Allowlisted keys populate `values`; lookup keys (`identity`/`mac`/`ip`)
|
|
21
|
+
* populate `lookups`; geo facts (`lat`/`lon`/`altitude`/`altitude-type`,
|
|
22
|
+
* issue #146 Slice 1) populate `geo` — all three are recognized (no warning).
|
|
23
|
+
* First-class CDB keys are rejected with a `cdb/reserved-option` warning
|
|
24
|
+
* (they have dedicated CDB tags and must not live in the comment). Any other
|
|
25
|
+
* key emits `cdb/unknown-option`.
|
|
23
26
|
*
|
|
24
27
|
* This module is intentionally pure: it returns raw string values. Type
|
|
25
28
|
* coercion (`validate` -> boolean, `timeout`/`port` -> integer) and settings
|
|
@@ -52,6 +55,23 @@ export const commentKvLookupKeys = ["identity", "mac", "ip"] as const;
|
|
|
52
55
|
|
|
53
56
|
export type CommentKvLookupKey = (typeof commentKvLookupKeys)[number];
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Device GPS facts (issue #146 Slice 1): recognized comment-kv keys that are
|
|
60
|
+
* **facts, not settings** — queryable via `--where` and surfaced in `devices
|
|
61
|
+
* show`/`list`'s `location` block, but never coerced into resolver settings
|
|
62
|
+
* (distinct from both the override allowlist above and the lookup keys below).
|
|
63
|
+
* See `commands/devices/README.md` (Location / GPS) and `src/resolver/geo.ts`
|
|
64
|
+
* for parsing/validation.
|
|
65
|
+
*/
|
|
66
|
+
export const commentKvGeoKeys = [
|
|
67
|
+
"lat",
|
|
68
|
+
"lon",
|
|
69
|
+
"altitude",
|
|
70
|
+
"altitude-type",
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
export type CommentKvGeoKey = (typeof commentKvGeoKeys)[number];
|
|
74
|
+
|
|
55
75
|
/**
|
|
56
76
|
* First-class CDB fields. These have dedicated CDB tags and must never be
|
|
57
77
|
* expressed through the comment kv-soup; `devices set` refuses to write them
|
|
@@ -76,6 +96,8 @@ export interface CommentKvResult {
|
|
|
76
96
|
values: Partial<Record<CommentKvKey, string>>;
|
|
77
97
|
/** Lookup keys (identity/mac/ip), last-wins, raw string values. */
|
|
78
98
|
lookups: Partial<Record<CommentKvLookupKey, string>>;
|
|
99
|
+
/** Geo facts (lat/lon/altitude/altitude-type), last-wins, raw string values. */
|
|
100
|
+
geo: Partial<Record<CommentKvGeoKey, string>>;
|
|
79
101
|
warnings: CommentKvWarning[];
|
|
80
102
|
}
|
|
81
103
|
|
|
@@ -95,6 +117,7 @@ const BARE_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
|
95
117
|
const allowSet = new Set<string>(commentKvAllowlist);
|
|
96
118
|
const reservedSet = new Set<string>(commentKvReservedKeys);
|
|
97
119
|
const lookupSet = new Set<string>(commentKvLookupKeys);
|
|
120
|
+
const geoSet = new Set<string>(commentKvGeoKeys);
|
|
98
121
|
|
|
99
122
|
function isAllowlistKey(key: string): key is CommentKvKey {
|
|
100
123
|
return allowSet.has(key);
|
|
@@ -104,6 +127,10 @@ function isLookupKey(key: string): key is CommentKvLookupKey {
|
|
|
104
127
|
return lookupSet.has(key);
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
function isGeoKey(key: string): key is CommentKvGeoKey {
|
|
131
|
+
return geoSet.has(key);
|
|
132
|
+
}
|
|
133
|
+
|
|
107
134
|
/**
|
|
108
135
|
* Shell-word tokenize a comment, tracking which characters originated inside a
|
|
109
136
|
* quoted span so the kv splitter can require a bare, unquoted key.
|
|
@@ -208,6 +235,7 @@ function splitKv(word: ShellWord): { key: string; value: string } | undefined {
|
|
|
208
235
|
export function parseCommentKv(comment: string): CommentKvResult {
|
|
209
236
|
const values: Partial<Record<CommentKvKey, string>> = {};
|
|
210
237
|
const lookups: Partial<Record<CommentKvLookupKey, string>> = {};
|
|
238
|
+
const geo: Partial<Record<CommentKvGeoKey, string>> = {};
|
|
211
239
|
const warnings: CommentKvWarning[] = [];
|
|
212
240
|
|
|
213
241
|
for (const word of tokenize(comment)) {
|
|
@@ -219,6 +247,10 @@ export function parseCommentKv(comment: string): CommentKvResult {
|
|
|
219
247
|
lookups[kv.key] = kv.value;
|
|
220
248
|
continue;
|
|
221
249
|
}
|
|
250
|
+
if (isGeoKey(kv.key)) {
|
|
251
|
+
geo[kv.key] = kv.value;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
222
254
|
if (isAllowlistKey(kv.key)) {
|
|
223
255
|
values[kv.key] = kv.value;
|
|
224
256
|
continue;
|
|
@@ -238,7 +270,29 @@ export function parseCommentKv(comment: string): CommentKvResult {
|
|
|
238
270
|
});
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
return { values, lookups, warnings };
|
|
273
|
+
return { values, lookups, geo, warnings };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read EVERY well-formed `key=value` token from a comment as raw facts —
|
|
278
|
+
* **no allowlist filter, no warnings, no setting coercion**. This is the
|
|
279
|
+
* device-class selector surface (`--where`): derived facts a record carries in
|
|
280
|
+
* its comment (e.g. `board=RB5009`, `version=7.23.1`) are real selectors here,
|
|
281
|
+
* even though they are NOT settings (so {@link parseCommentKv} drops them with a
|
|
282
|
+
* `cdb/unknown-option` warning). Last token wins on a duplicate bare key. Core
|
|
283
|
+
* CDB fields (`target`/`identity`/`group`/`mac`) are layered ON TOP of this map
|
|
284
|
+
* by the selector and win, so a hand-written `group=`/`target=` comment token
|
|
285
|
+
* can never spoof a first-class field.
|
|
286
|
+
*/
|
|
287
|
+
export function parseRawCommentFacts(comment: string): Record<string, string> {
|
|
288
|
+
const facts: Record<string, string> = {};
|
|
289
|
+
for (const word of tokenize(comment)) {
|
|
290
|
+
const kv = splitKv(word);
|
|
291
|
+
if (kv) {
|
|
292
|
+
facts[kv.key] = kv.value;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return facts;
|
|
242
296
|
}
|
|
243
297
|
|
|
244
298
|
/**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centrs.env` — the user-global config-file precedence tier (constitution:
|
|
3
|
+
* "Settings precedence", `default < config < comment-kv < env < cli`).
|
|
4
|
+
*
|
|
5
|
+
* Two read paths, deliberately kept separate:
|
|
6
|
+
*
|
|
7
|
+
* - {@link loadEnvFileDefaults} is what every *other* command calls to pick
|
|
8
|
+
* up the `config` tier. It honors `CENTRS_SKIP_ENV_FILE` (returns `{}` when
|
|
9
|
+
* set) and is safe to call unconditionally once per invocation.
|
|
10
|
+
* - {@link readSettingsFileRaw} is what the `settings` command itself calls.
|
|
11
|
+
* `settings` always inspects/edits the real file regardless of
|
|
12
|
+
* `--skip-env-file` — that flag controls whether *other* commands see the
|
|
13
|
+
* file's values, not whether `settings` can see the file.
|
|
14
|
+
*
|
|
15
|
+
* The path resolver takes an injectable `env` map (mirroring
|
|
16
|
+
* `defaultCdbPath` in `src/devices.ts`) so tests can point
|
|
17
|
+
* `XDG_CONFIG_HOME` at a per-test temp directory instead of touching a real
|
|
18
|
+
* user's `~/.config`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { parseBoolean, REFUSED_CONFIG_ENV_KEYS } from "./settings.ts";
|
|
24
|
+
|
|
25
|
+
const ENV_XDG_CONFIG_HOME = "XDG_CONFIG_HOME";
|
|
26
|
+
const ENV_HOME = "HOME";
|
|
27
|
+
const ENV_SKIP_ENV_FILE = "CENTRS_SKIP_ENV_FILE";
|
|
28
|
+
const CONFIG_RELATIVE_DIR = "tikoci";
|
|
29
|
+
const CONFIG_FILE_NAME = "centrs.env";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve `${XDG_CONFIG_HOME:-~/.config}/tikoci/centrs.env`. This is a
|
|
33
|
+
* deliberately different base-path convention from `defaultCdbPath`'s
|
|
34
|
+
* `$HOME`-only scheme — the constitution and the settings spec already
|
|
35
|
+
* commit to XDG for this file specifically.
|
|
36
|
+
*/
|
|
37
|
+
export function defaultSettingsPath(
|
|
38
|
+
env?: Record<string, string | undefined>,
|
|
39
|
+
): string {
|
|
40
|
+
const xdgConfigHome = env?.[ENV_XDG_CONFIG_HOME];
|
|
41
|
+
const configHome =
|
|
42
|
+
xdgConfigHome && xdgConfigHome.length > 0
|
|
43
|
+
? xdgConfigHome
|
|
44
|
+
: join(env?.[ENV_HOME] ?? homedir(), ".config");
|
|
45
|
+
return join(configHome, CONFIG_RELATIVE_DIR, CONFIG_FILE_NAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSkippingEnvFile(env: Record<string, string | undefined>): boolean {
|
|
49
|
+
const raw = env[ENV_SKIP_ENV_FILE];
|
|
50
|
+
if (raw === undefined) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return parseBoolean(raw, ENV_SKIP_ENV_FILE);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SettingsFileContents {
|
|
61
|
+
readonly exists: boolean;
|
|
62
|
+
/** Every non-blank, non-comment line verbatim, in file order. */
|
|
63
|
+
readonly lines: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Raw line-level read, no `CENTRS_SKIP_ENV_FILE` short-circuit. Used by
|
|
68
|
+
* `settings` itself, which must always see the real file.
|
|
69
|
+
*/
|
|
70
|
+
export async function readSettingsFileRaw(
|
|
71
|
+
path: string,
|
|
72
|
+
): Promise<SettingsFileContents> {
|
|
73
|
+
const file = Bun.file(path);
|
|
74
|
+
if (!(await file.exists())) {
|
|
75
|
+
return { exists: false, lines: [] };
|
|
76
|
+
}
|
|
77
|
+
const text = await file.text();
|
|
78
|
+
return { exists: true, lines: text.split("\n") };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse `CENTRS_*=value` lines into a flat map. Blank lines and `#`
|
|
83
|
+
* comments are ignored; a later duplicate key wins. Malformed lines
|
|
84
|
+
* (no `=`, or a key that isn't a bare identifier) are silently skipped —
|
|
85
|
+
* a hand-edited file with foreign content should not fail every other
|
|
86
|
+
* command's settings resolution.
|
|
87
|
+
*/
|
|
88
|
+
export function parseEnvFileDefaults(
|
|
89
|
+
lines: readonly string[],
|
|
90
|
+
): Record<string, string> {
|
|
91
|
+
const result: Record<string, string> = {};
|
|
92
|
+
for (const rawLine of lines) {
|
|
93
|
+
const line = rawLine.trim();
|
|
94
|
+
if (line.length === 0 || line.startsWith("#")) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const separatorIndex = line.indexOf("=");
|
|
98
|
+
if (separatorIndex <= 0) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
102
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
result[key] = line.slice(separatorIndex + 1).trim();
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `config` tier every command resolver call should pass through. Empty
|
|
112
|
+
* (`{}`) when the file is absent or `CENTRS_SKIP_ENV_FILE` is active — never
|
|
113
|
+
* throws, since a missing or foreign-content file is not an error for
|
|
114
|
+
* anything but `settings` itself.
|
|
115
|
+
*
|
|
116
|
+
* Credential/self-referential keys ({@link REFUSED_CONFIG_ENV_KEYS}) are
|
|
117
|
+
* stripped even if a hand-edited file contains them: most resolvers already
|
|
118
|
+
* never consult `config` for these, but dropping them here means the shared
|
|
119
|
+
* `config` map itself never carries a secret, rather than relying on every
|
|
120
|
+
* call site to keep excluding it. `parseEnvFileDefaults` is left unfiltered
|
|
121
|
+
* so `settings print --all` can still inspect/redact a hand-added line.
|
|
122
|
+
*/
|
|
123
|
+
export async function loadEnvFileDefaults(
|
|
124
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
125
|
+
): Promise<Record<string, string>> {
|
|
126
|
+
if (isSkippingEnvFile(env)) {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
const path = defaultSettingsPath(env);
|
|
130
|
+
const { lines } = await readSettingsFileRaw(path);
|
|
131
|
+
const parsed = parseEnvFileDefaults(lines);
|
|
132
|
+
for (const key of REFUSED_CONFIG_ENV_KEYS) {
|
|
133
|
+
delete parsed[key];
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-class fact extraction + selector matching, shared by the fan-out
|
|
3
|
+
* resolver ({@link ../resolver/selection.ts | expandSelection}) and
|
|
4
|
+
* `devices list` (`src/devices.ts`) so **both surfaces match `--where` /
|
|
5
|
+
* `--near` / `--bbox` against identical semantics**.
|
|
6
|
+
*
|
|
7
|
+
* This is a deliberate leaf module — it imports only from `comment-kv.ts`,
|
|
8
|
+
* `mac.ts`, and `geo.ts` (all leaves). It exists because `src/devices.ts` and
|
|
9
|
+
* `src/resolver/cdb.ts` form a cycle (`cdb.ts` imports `devices.ts`), so
|
|
10
|
+
* `devices.ts` cannot import `cdb.ts`'s `identityFromComment`/`macFromComment`
|
|
11
|
+
* nor `selection.ts`'s local `entryFacts` without a circular import. That forced
|
|
12
|
+
* the `--where` fact-builder to be duplicated in both files; hosting it here
|
|
13
|
+
* removes the duplication and the "keep the two in sync" hazard.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parseCommentKv, parseRawCommentFacts } from "./comment-kv.ts";
|
|
17
|
+
import {
|
|
18
|
+
canonicalizeGeoKey,
|
|
19
|
+
type DeviceLocation,
|
|
20
|
+
deviceLocation,
|
|
21
|
+
} from "./geo.ts";
|
|
22
|
+
import { normalizeMac } from "./mac.ts";
|
|
23
|
+
|
|
24
|
+
/** One exact-match device-class clause (`attr=value`), AND-combined by callers. */
|
|
25
|
+
export interface WhereClause {
|
|
26
|
+
key: string;
|
|
27
|
+
value: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turn a CDB entry's `(comment, target, group)` into the device-class fact map
|
|
32
|
+
* `--where` matches against: every raw comment-kv token, with the core fields
|
|
33
|
+
* (`target`/`identity`/`group`/`mac`) layered **on top** so a hand-written
|
|
34
|
+
* comment token can never spoof a first-class field. MAC is normalized so a
|
|
35
|
+
* `mac=` lookup or a MAC `target` compares separator/case-insensitively.
|
|
36
|
+
*/
|
|
37
|
+
export function entryFacts(
|
|
38
|
+
comment: string,
|
|
39
|
+
target: string,
|
|
40
|
+
group: string,
|
|
41
|
+
): Record<string, string> {
|
|
42
|
+
const facts = parseRawCommentFacts(comment);
|
|
43
|
+
const lookups = parseCommentKv(comment).lookups;
|
|
44
|
+
facts["target"] = target;
|
|
45
|
+
facts["identity"] = lookups.identity ?? target;
|
|
46
|
+
facts["group"] = group;
|
|
47
|
+
const mac = normalizeMac(lookups.mac ?? "") ?? normalizeMac(target);
|
|
48
|
+
if (mac !== undefined) {
|
|
49
|
+
facts["mac"] = mac;
|
|
50
|
+
}
|
|
51
|
+
return facts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when every `--where` clause matches the entry's facts (exact,
|
|
56
|
+
* AND-combined). The clause key is run through {@link canonicalizeGeoKey} here —
|
|
57
|
+
* the single seam — so a geo alias (`--where latitude=…`/`lng=…`/`elevation=…`)
|
|
58
|
+
* matches the canonical `lat`/`lon`/`altitude` fact the write side stores, on
|
|
59
|
+
* BOTH `devices list` and every fan-out command's `--where`. A no-op for
|
|
60
|
+
* non-geo keys.
|
|
61
|
+
*/
|
|
62
|
+
export function matchesWhere(
|
|
63
|
+
facts: Record<string, string>,
|
|
64
|
+
where: readonly WhereClause[],
|
|
65
|
+
): boolean {
|
|
66
|
+
return where.every(
|
|
67
|
+
(clause) => facts[canonicalizeGeoKey(clause.key)] === clause.value,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read a CDB entry's GPS location from its comment, or `undefined` when it
|
|
73
|
+
* carries no well-formed `lat`/`lon`. The single seam `--near`/`--bbox` use to
|
|
74
|
+
* exclude geo-less devices (never an error — they simply don't match).
|
|
75
|
+
*/
|
|
76
|
+
export function entryLocation(comment: string): DeviceLocation | undefined {
|
|
77
|
+
return deviceLocation(parseRawCommentFacts(comment));
|
|
78
|
+
}
|