@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -348,6 +348,32 @@ interface PendingCommand {
348
348
  reject: (error: CentrsError) => void;
349
349
  }
350
350
 
351
+ /**
352
+ * A long-lived `/listen` subscription. Unlike {@link PendingCommand} it never
353
+ * buffers to a single resolve — each `!re` is pushed to the consumer as it
354
+ * arrives, and the stream ends on `!done` (after a `/cancel`'s `interrupted`
355
+ * trap) or fails on `!fatal`/transport closure.
356
+ */
357
+ interface StreamingSubscription {
358
+ command: string;
359
+ /** An `!re` change frame arrived. */
360
+ push: (reply: ApiReply) => void;
361
+ /** An `!trap` arrived (an `interrupted` trap is the normal `/cancel` path). */
362
+ trap: (reply: ApiReply) => void;
363
+ /** `!done` arrived — the listen closed. */
364
+ settle: () => void;
365
+ /** `!fatal` or transport closure — the stream cannot continue. */
366
+ fail: (error: CentrsError) => void;
367
+ }
368
+
369
+ /** A `/cancel`'s `interrupted` trap (category 2) is the expected end of a listen, not an error. */
370
+ function isInterruptedTrap(trap: ApiReply): boolean {
371
+ const message = readAttribute(trap, "message") ?? "";
372
+ return (
373
+ /interrupted/i.test(message) || readAttribute(trap, "category") === "2"
374
+ );
375
+ }
376
+
351
377
  export interface NativeApiSessionOptions {
352
378
  sink: NativeApiByteSink;
353
379
  /** Host/port label used in error envelopes. */
@@ -366,6 +392,7 @@ export class NativeApiSession {
366
392
  private readonly endpoint: string;
367
393
  private readonly reader = new SentenceReader();
368
394
  private readonly pending = new Map<string, PendingCommand>();
395
+ private readonly subscriptions = new Map<string, StreamingSubscription>();
369
396
  private tagCounter = 0;
370
397
  private closed = false;
371
398
  private closeError: CentrsError | undefined;
@@ -439,33 +466,51 @@ export class NativeApiSession {
439
466
  return; // untagged async/system notice — no waiter to route to
440
467
  }
441
468
  const waiter = this.pending.get(tag);
442
- if (waiter === undefined) {
443
- return;
469
+ if (waiter !== undefined) {
470
+ switch (reply.type) {
471
+ case "!re":
472
+ waiter.records.push(reply);
473
+ return;
474
+ case "!trap":
475
+ waiter.trap = reply;
476
+ return;
477
+ case "!empty":
478
+ return;
479
+ case "!done": {
480
+ this.pending.delete(tag);
481
+ if (waiter.trap) {
482
+ waiter.reject(this.trapToError(waiter.command, waiter.trap));
483
+ return;
484
+ }
485
+ if (Object.keys(reply.attributes).length > 0) {
486
+ waiter.records.push(reply);
487
+ }
488
+ waiter.resolve(waiter.records);
489
+ return;
490
+ }
491
+ default:
492
+ return;
493
+ }
444
494
  }
445
495
 
446
- switch (reply.type) {
447
- case "!re":
448
- waiter.records.push(reply);
449
- return;
450
- case "!trap":
451
- waiter.trap = reply;
452
- return;
453
- case "!empty":
454
- return;
455
- case "!done": {
456
- this.pending.delete(tag);
457
- if (waiter.trap) {
458
- waiter.reject(this.trapToError(waiter.command, waiter.trap));
496
+ const subscription = this.subscriptions.get(tag);
497
+ if (subscription !== undefined) {
498
+ switch (reply.type) {
499
+ case "!re":
500
+ subscription.push(reply);
501
+ return;
502
+ case "!trap":
503
+ subscription.trap(reply);
504
+ return;
505
+ case "!empty":
506
+ return;
507
+ case "!done":
508
+ this.subscriptions.delete(tag);
509
+ subscription.settle();
510
+ return;
511
+ default:
459
512
  return;
460
- }
461
- if (Object.keys(reply.attributes).length > 0) {
462
- waiter.records.push(reply);
463
- }
464
- waiter.resolve(waiter.records);
465
- return;
466
513
  }
467
- default:
468
- return;
469
514
  }
470
515
  }
471
516
 
@@ -503,6 +548,11 @@ export class NativeApiSession {
503
548
  for (const waiter of waiters) {
504
549
  waiter.reject(error);
505
550
  }
551
+ const subscriptions = [...this.subscriptions.values()];
552
+ this.subscriptions.clear();
553
+ for (const subscription of subscriptions) {
554
+ subscription.fail(error);
555
+ }
506
556
  }
507
557
 
508
558
  private nextTag(): string {
@@ -573,6 +623,145 @@ export class NativeApiSession {
573
623
  });
574
624
  }
575
625
 
626
+ /**
627
+ * Open a `/listen` subscription and yield each `!re` change frame as it
628
+ * arrives (the open-ended follow path; the listen command never sends `!done`
629
+ * on its own). The generator ends cleanly when the listen is cancelled:
630
+ * - via `options.signal` aborting — the subscription stays registered, so the
631
+ * resulting `interrupted` trap + `!done` are drained and the loop ends
632
+ * without throwing;
633
+ * - via the consumer breaking out of the `for await` — `finally` sends
634
+ * `/cancel` best-effort (the subscription is already torn down, so the
635
+ * trap/`!done` are not awaited) and the generator returns.
636
+ * A non-`interrupted` trap (e.g. a bad path) or `!fatal`/transport closure
637
+ * throws. CHR-grounded (7.23.1): see `commands/api/AGENTS.md`.
638
+ */
639
+ async *listen(
640
+ command: NativeApiCommand,
641
+ options: { signal?: AbortSignal; onListening?: () => void } = {},
642
+ ): AsyncGenerator<ApiReply, void, void> {
643
+ if (this.closed) {
644
+ throw (
645
+ this.closeError ??
646
+ new CentrsError({
647
+ code: "transport/connection-closed",
648
+ summary: `The RouterOS API session to ${this.endpoint} is closed.`,
649
+ remediation: "Open a new connection before listening.",
650
+ })
651
+ );
652
+ }
653
+ const tag = this.nextTag();
654
+ const words = this.buildWords(command, tag);
655
+
656
+ const queue: ApiReply[] = [];
657
+ let wake: (() => void) | undefined;
658
+ let ended = false;
659
+ let failure: CentrsError | undefined;
660
+ let trap: ApiReply | undefined;
661
+ const signalWake = (): void => {
662
+ const resume = wake;
663
+ wake = undefined;
664
+ resume?.();
665
+ };
666
+
667
+ this.subscriptions.set(tag, {
668
+ command: command.command,
669
+ push: (reply) => {
670
+ queue.push(reply);
671
+ signalWake();
672
+ },
673
+ trap: (reply) => {
674
+ trap = reply;
675
+ },
676
+ settle: () => {
677
+ ended = true;
678
+ signalWake();
679
+ },
680
+ fail: (error) => {
681
+ failure = error;
682
+ ended = true;
683
+ signalWake();
684
+ },
685
+ });
686
+
687
+ try {
688
+ this.sink.write(encodeSentence(words));
689
+ } catch (error) {
690
+ this.subscriptions.delete(tag);
691
+ throw error instanceof CentrsError
692
+ ? error
693
+ : new CentrsError({
694
+ code: "transport/network",
695
+ summary: `Failed to open a listen on the RouterOS API connection to ${this.endpoint}.`,
696
+ remediation: "Retry; confirm the connection is still open.",
697
+ cause: error,
698
+ });
699
+ }
700
+
701
+ // The subscription is on the wire — signal "listening established" so callers
702
+ // can act on a real barrier rather than a blind timer.
703
+ options.onListening?.();
704
+
705
+ const onAbort = (): void => this.cancel(tag);
706
+ if (options.signal) {
707
+ if (options.signal.aborted) {
708
+ this.cancel(tag);
709
+ } else {
710
+ options.signal.addEventListener("abort", onAbort, { once: true });
711
+ }
712
+ }
713
+
714
+ try {
715
+ for (;;) {
716
+ if (queue.length > 0) {
717
+ // Drain the whole batch at once: repeated `shift()` is O(n) per frame
718
+ // (it shifts every remaining element), so a high-volume listen would
719
+ // be O(n²). New
720
+ // frames pushed while we yield land in the freshly emptied queue and
721
+ // drain on the next pass.
722
+ const batch = queue.splice(0, queue.length);
723
+ for (const reply of batch) {
724
+ yield reply;
725
+ }
726
+ } else if (ended) {
727
+ break;
728
+ } else {
729
+ await new Promise<void>((resolve) => {
730
+ wake = resolve;
731
+ });
732
+ }
733
+ }
734
+ if (failure) {
735
+ throw failure;
736
+ }
737
+ if (trap && !isInterruptedTrap(trap)) {
738
+ throw this.trapToError(command.command, trap);
739
+ }
740
+ } finally {
741
+ options.signal?.removeEventListener("abort", onAbort);
742
+ this.subscriptions.delete(tag);
743
+ // Consumer broke out early (e.g. --count reached): tell the router to stop.
744
+ if (!ended && !this.closed) {
745
+ this.cancel(tag);
746
+ }
747
+ }
748
+ }
749
+
750
+ /** Cancel an in-flight `/listen` by its tag (`/cancel =tag=<tag> .tag=<new>`). */
751
+ private cancel(tag: string): void {
752
+ if (this.closed) {
753
+ return;
754
+ }
755
+ const cancelTag = this.nextTag();
756
+ try {
757
+ this.sink.write(
758
+ encodeSentence(["/cancel", `=tag=${tag}`, `.tag=${cancelTag}`]),
759
+ );
760
+ } catch {
761
+ // Best effort — the connection may already be gone.
762
+ }
763
+ }
764
+
576
765
  /**
577
766
  * Authenticate. Uses the combined approach: send name+password; if the
578
767
  * router answers with a legacy `=ret=` challenge, compute the MD5 response
@@ -607,16 +796,20 @@ export class NativeApiSession {
607
796
  try {
608
797
  this.sink.close();
609
798
  } finally {
799
+ const closeError = new CentrsError({
800
+ code: "transport/connection-closed",
801
+ summary: `The RouterOS API session to ${this.endpoint} was closed by the caller.`,
802
+ remediation: "Open a new connection before sending more commands.",
803
+ });
610
804
  const waiters = [...this.pending.values()];
611
805
  this.pending.clear();
612
806
  for (const waiter of waiters) {
613
- waiter.reject(
614
- new CentrsError({
615
- code: "transport/connection-closed",
616
- summary: `The RouterOS API session to ${this.endpoint} was closed by the caller.`,
617
- remediation: "Open a new connection before sending more commands.",
618
- }),
619
- );
807
+ waiter.reject(closeError);
808
+ }
809
+ const subscriptions = [...this.subscriptions.values()];
810
+ this.subscriptions.clear();
811
+ for (const subscription of subscriptions) {
812
+ subscription.fail(closeError);
620
813
  }
621
814
  }
622
815
  }
@@ -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 | 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
+ }