@tikoci/centrs 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
@@ -610,23 +610,39 @@ class NativeApiAdapter implements ProtocolAdapter {
610
610
  async execute(
611
611
  request: ProtocolExecuteRequest,
612
612
  ): Promise<ProtocolExecuteResult> {
613
- const command: NativeApiCommand = request.script
614
- ? { command: "/execute", attributes: { script: request.script } }
615
- : { command: `${request.path.replace(/\/$/, "")}/${request.command}` };
616
- if (
617
- !request.script &&
618
- request.attributes &&
619
- Object.keys(request.attributes).length > 0
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 (!request.script && request.queries && request.queries.length > 0) {
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
- // `as-string` makes `/execute` run synchronously and return the captured
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>[] };
@@ -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. Important execute/terminal path after grounding.",
48
- implemented: false,
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: "Passive hint source only; not authoritative inventory.",
60
- implemented: false,
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
- * (semantic) gate — no `/console/inspect` table parsing needed.
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's `:parse`
327
- * reports `bad parameter <name>` as well as `syntax error`.
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 output of a console `:put [:parse "<cli>"]`. A single console
609
- * `:parse` reports both forms (grounded on CHR 7.23.1): `bad parameter <name>`
610
- * for an unknown attribute and `syntax error` / `bad command name` for malformed
611
- * CLI. Throws the matching `validation/*` error; returns on a clean parse.
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(output: string, cli: string): void {
614
- // The console `:parse` rejection carries RouterOS's authoritative byte offset
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 = output.match(/bad parameter\s+(\S+)/i);
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: ":put [:parse ...] over mac-telnet",
675
+ validationSource: `:put [:parse ...] over ${via}`,
628
676
  detail: output,
629
677
  },
630
678
  ...(position ? { position } : {}),
631
679
  });
632
680
  }
633
- if (/syntax error|bad command name|expected\b/i.test(output)) {
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: ":put [:parse ...] over mac-telnet",
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
  }
@@ -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`. First-class CDB keys are rejected with
21
- * a `cdb/reserved-option` warning (they have dedicated CDB tags and must not
22
- * live in the comment). Any other key emits `cdb/unknown-option`.
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
+ }