@tikoci/centrs 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -2
- package/GLOSSARY.txt +87 -0
- package/README.md +25 -9
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +177 -0
- package/commands/api/examples.md +428 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +48 -7
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +37 -51
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +40 -15
- package/docs/MATRIX.md +69 -142
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +9 -9
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +1627 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +660 -0
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +75 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +29 -0
- package/src/core/error-catalog.ts +69 -0
- package/src/core/fanout.ts +301 -0
- package/src/core/inspect.ts +170 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +134 -70
- package/src/index.ts +166 -9
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +357 -9
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/index.ts +11 -5
- package/src/protocols/native-api.ts +223 -30
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +232 -268
- package/src/retrieve.ts +69 -94
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
- package/commands/stream/README.md +0 -70
|
@@ -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
|
|
443
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
}
|
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 | 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
|
+
}
|