@tikoci/centrs 0.1.2 → 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.
Files changed (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
@@ -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 | expandCdbSelection}) 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
+ }