@tikoci/centrs 0.1.1 → 0.1.2

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.
@@ -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
  }
@@ -32,6 +32,13 @@ import type {
32
32
  FanoutData,
33
33
  FanoutSummary,
34
34
  } from "./core/envelope.ts";
35
+ import {
36
+ defaultFanoutSleep,
37
+ resolveFanoutConcurrency,
38
+ runBoundedPool,
39
+ runWithRetry,
40
+ summarizeFanout,
41
+ } from "./core/fanout.ts";
35
42
  import { CentrsError, serializeCentrsError } from "./errors.ts";
36
43
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
37
44
  import {
@@ -57,27 +64,6 @@ import {
57
64
  validateRetrieveRequestShape,
58
65
  } from "./retrieve.ts";
59
66
 
60
- /** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
61
- export const RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS: Record<
62
- "rest-api" | "native-api",
63
- number
64
- > = {
65
- "rest-api": 8,
66
- "native-api": 4,
67
- };
68
-
69
- /** Extra attempts after the first try (jittered backoff between them). */
70
- export const RETRIEVE_FANOUT_MAX_RETRIES = 2;
71
-
72
- /** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
73
- export const RETRIEVE_FANOUT_BACKOFF_BASE_MS = 200;
74
-
75
- /** RouterOS-style codes that are safe to retry under fanout. */
76
- export const RETRIEVE_FANOUT_RETRYABLE_CODES = [
77
- "transport/network",
78
- "transport/connection-closed",
79
- ] as const;
80
-
81
67
  export interface RetrieveFanoutOperationMeta {
82
68
  kind: "fanout";
83
69
  group: string;
@@ -176,7 +162,7 @@ export async function retrieveGroup(
176
162
  }
177
163
 
178
164
  const execute = internals.execute ?? runResolvedRetrieve;
179
- const sleep = internals.sleep ?? defaultSleep;
165
+ const sleep = internals.sleep ?? defaultFanoutSleep;
180
166
 
181
167
  const targets = await runBoundedPool(
182
168
  expansion.targets,
@@ -199,7 +185,11 @@ export async function retrieveGroup(
199
185
  } catch (error) {
200
186
  return buildResolveFailureEnvelope(member.resolution, error);
201
187
  }
202
- return runTargetWithRetry(resolved, member.recordIndex, execute, sleep);
188
+ return runWithRetry<RetrieveEnvelope>(
189
+ () => execute(resolved, member.recordIndex),
190
+ (error) => buildRetrieveErrorEnvelopeFromResolved(resolved, error),
191
+ { sleep },
192
+ );
203
193
  },
204
194
  );
205
195
 
@@ -251,28 +241,6 @@ export function buildRetrieveFanoutErrorEnvelope(
251
241
  };
252
242
  }
253
243
 
254
- /** Resolve and validate the worker-pool size. */
255
- export function resolveFanoutConcurrency(
256
- requested: number | undefined,
257
- via: RouterOsProtocol,
258
- ): number {
259
- if (requested !== undefined) {
260
- if (!Number.isInteger(requested) || requested < 1) {
261
- throw new CentrsError({
262
- code: "usage/invalid-concurrency",
263
- summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
264
- remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
265
- context: { concurrency: requested },
266
- });
267
- }
268
- return requested;
269
- }
270
- if (via === "native-api") {
271
- return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["native-api"];
272
- }
273
- return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
274
- }
275
-
276
244
  function resolveFanoutConcurrencyProtocol(
277
245
  request: RetrieveRequest,
278
246
  env: Record<string, string | undefined>,
@@ -292,93 +260,6 @@ function resolveFanoutConcurrencyProtocol(
292
260
  return globalVia;
293
261
  }
294
262
 
295
- /**
296
- * Classify a per-target failure as retryable. Only the locked allowlist
297
- * retries: generic transport/network failures and dropped/closed connections
298
- * (including REST 5xx mapped to `transport/connection-closed`). Router-side
299
- * (`routeros/*`), validation, auth, cdb, target, input, usage, timeout, TLS,
300
- * connection-refused, and DNS failures are deterministic and never retried.
301
- */
302
- export function isRetryableFanoutError(error: unknown): boolean {
303
- if (!(error instanceof CentrsError)) {
304
- return false;
305
- }
306
- return (RETRIEVE_FANOUT_RETRYABLE_CODES as readonly string[]).includes(
307
- error.code,
308
- );
309
- }
310
-
311
- export function summarizeFanout(
312
- targets: readonly RetrieveEnvelope[],
313
- ): FanoutSummary {
314
- let ok = 0;
315
- let failed = 0;
316
- for (const target of targets) {
317
- if (target.ok) {
318
- ok += 1;
319
- } else {
320
- failed += 1;
321
- }
322
- }
323
- return { total: targets.length, ok, failed };
324
- }
325
-
326
- async function runTargetWithRetry(
327
- resolved: ResolvedRetrieveRequest,
328
- recordIndex: number,
329
- execute: (
330
- resolved: ResolvedRetrieveRequest,
331
- recordIndex: number,
332
- ) => Promise<RetrieveSuccessEnvelope>,
333
- sleep: (ms: number) => Promise<void>,
334
- ): Promise<RetrieveEnvelope> {
335
- let attempt = 0;
336
- for (;;) {
337
- try {
338
- return await execute(resolved, recordIndex);
339
- } catch (error) {
340
- if (
341
- attempt < RETRIEVE_FANOUT_MAX_RETRIES &&
342
- isRetryableFanoutError(error)
343
- ) {
344
- attempt += 1;
345
- await sleep(backoffMs(attempt));
346
- continue;
347
- }
348
- return buildRetrieveErrorEnvelopeFromResolved(resolved, error);
349
- }
350
- }
351
- }
352
-
353
- /**
354
- * Ordered bounded worker pool. Runs `worker` over `items` with at most
355
- * `concurrency` in flight, returning results in INPUT order regardless of
356
- * completion order.
357
- */
358
- export async function runBoundedPool<I, O>(
359
- items: readonly I[],
360
- concurrency: number,
361
- worker: (item: I, index: number) => Promise<O>,
362
- ): Promise<O[]> {
363
- const results = new Array<O>(items.length);
364
- let next = 0;
365
- const width = Math.max(1, Math.min(concurrency, items.length));
366
-
367
- async function drain(): Promise<void> {
368
- for (;;) {
369
- const index = next;
370
- next += 1;
371
- if (index >= items.length) {
372
- return;
373
- }
374
- results[index] = await worker(items[index] as I, index);
375
- }
376
- }
377
-
378
- await Promise.all(Array.from({ length: width }, () => drain()));
379
- return results;
380
- }
381
-
382
263
  function buildResolveFailureEnvelope(
383
264
  resolution: CdbResolution,
384
265
  error: unknown,
@@ -460,15 +341,6 @@ function commonVia(
460
341
  return via ?? null;
461
342
  }
462
343
 
463
- function backoffMs(attempt: number): number {
464
- const base = RETRIEVE_FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
465
- return base + Math.floor(Math.random() * RETRIEVE_FANOUT_BACKOFF_BASE_MS);
466
- }
467
-
468
- function defaultSleep(ms: number): Promise<void> {
469
- return new Promise((resolve) => setTimeout(resolve, ms));
470
- }
471
-
472
344
  /**
473
345
  * Render a fanout envelope. `json`/`yaml` serialize the whole envelope (lossless
474
346
  * against the JSON shape); `text` renders a fanout summary line plus one line
package/src/retrieve.ts CHANGED
@@ -6,6 +6,15 @@ import type {
6
6
  SettingSource as CoreSettingSource,
7
7
  EnvelopeValidationMeta,
8
8
  } from "./core/envelope.ts";
9
+ import {
10
+ extractCompletionNames,
11
+ inspectChildren,
12
+ inspectChildrenOrEmpty,
13
+ inspectCompletions,
14
+ isArgumentNode,
15
+ isCommandNode,
16
+ pathTokens,
17
+ } from "./core/inspect.ts";
9
18
  import { CentrsError, serializeCentrsError } from "./errors.ts";
10
19
  import {
11
20
  createProtocolAdapter,
@@ -101,20 +110,6 @@ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
101
110
  >;
102
111
  export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
103
112
 
104
- interface InspectChildItem {
105
- type?: string;
106
- name?: string;
107
- "node-type"?: string;
108
- }
109
-
110
- interface InspectCompletionItem {
111
- type?: string;
112
- name?: string;
113
- completion?: string;
114
- value?: string;
115
- text?: string;
116
- }
117
-
118
113
  interface RetrieveInspection {
119
114
  command: "get" | "print";
120
115
  }
@@ -876,41 +871,19 @@ function resolveFormat(
876
871
  }
877
872
 
878
873
  /**
879
- * Path-existence probe. An invalid path raises a native trap that
880
- * {@link mapRouterOsError} normalizes to `routeros/unknown-path` (REST instead
881
- * returns an empty child list). Both the legacy `routeros/api-trap` catch-all
882
- * and the grounded `routeros/unknown-path` classification are flattened to an
883
- * empty list so the orchestrator surfaces a single `validation/unknown-path`.
884
- * Traps during attribute/completion discovery do NOT use this probe — they
885
- * surface as-is.
874
+ * Resolve the read command (`print` vs `get`) for a path via a `request=child`
875
+ * existence probe. Uses {@link inspectChildrenOrEmpty} so an invalid path (a
876
+ * native trap, or REST's empty child list) flattens to no children and surfaces
877
+ * as a single `validation/unknown-path` here rather than leaking a transport
878
+ * trap. Attribute/completion discovery does NOT use the swallowing probe — traps
879
+ * there surface as-is.
886
880
  */
887
- async function inspectChildrenForProbe(
888
- backend: ProtocolAdapter,
889
- path: string,
890
- ): Promise<unknown[]> {
891
- try {
892
- return await backend.inspect("child", path);
893
- } catch (error) {
894
- if (
895
- error instanceof CentrsError &&
896
- (error.code === "routeros/api-trap" ||
897
- error.code === "routeros/unknown-path")
898
- ) {
899
- return [];
900
- }
901
- throw error;
902
- }
903
- }
904
-
905
881
  async function inspectRetrievePath(
906
882
  resolved: ResolvedRetrieveRequest,
907
883
  backend: ProtocolAdapter,
908
884
  ): Promise<RetrieveInspection> {
909
- const pathTokens = pathTokensForInspect(resolved.path);
910
- const rootChildren = (await inspectChildrenForProbe(
911
- backend,
912
- pathToInspectString(pathTokens),
913
- )) as InspectChildItem[];
885
+ const tokens = pathTokens(resolved.path);
886
+ const rootChildren = await inspectChildrenOrEmpty(backend, tokens);
914
887
 
915
888
  const supportsPrint = rootChildren.some((child) =>
916
889
  isCommandNode(child, "print"),
@@ -943,21 +916,24 @@ async function inspectAttributes(
943
916
  inspection: RetrieveInspection,
944
917
  backend: ProtocolAdapter,
945
918
  ): Promise<string[]> {
946
- const pathTokens = pathTokensForInspect(resolved.path);
919
+ const tokens = pathTokens(resolved.path);
947
920
  const argument = inspection.command === "get" ? "value-name" : "proplist";
948
- const completionRows = (await backend.inspect(
949
- "completion",
950
- pathToInspectString([...pathTokens, inspection.command, argument]),
951
- )) as InspectCompletionItem[];
952
- const completions = extractCompletionNames(completionRows);
921
+ const completionRows = await inspectCompletions(backend, [
922
+ ...tokens,
923
+ inspection.command,
924
+ argument,
925
+ ]);
926
+ const completions = [
927
+ ...new Set(extractCompletionNames(completionRows)),
928
+ ].sort();
953
929
  if (completions.length > 0) {
954
930
  return completions;
955
931
  }
956
932
 
957
- const commandChildren = (await backend.inspect(
958
- "child",
959
- pathToInspectString([...pathTokens, inspection.command]),
960
- )) as InspectChildItem[];
933
+ const commandChildren = await inspectChildren(backend, [
934
+ ...tokens,
935
+ inspection.command,
936
+ ]);
961
937
  return commandChildren
962
938
  .filter(isArgumentNode)
963
939
  .map((child) => child.name)
@@ -985,35 +961,6 @@ async function executeRetrieve(
985
961
  });
986
962
  }
987
963
 
988
- function pathTokensForInspect(path: string): string[] {
989
- return path.split("/").filter(Boolean);
990
- }
991
-
992
- function isCommandNode(
993
- child: InspectChildItem,
994
- name: "get" | "print",
995
- ): boolean {
996
- return (
997
- child.name === name &&
998
- (child.type === "cmd" || child["node-type"] === "cmd")
999
- );
1000
- }
1001
-
1002
- function isArgumentNode(child: InspectChildItem): boolean {
1003
- return child.type === "arg" || child["node-type"] === "arg";
1004
- }
1005
-
1006
- function extractCompletionNames(
1007
- rows: readonly InspectCompletionItem[],
1008
- ): string[] {
1009
- const names = rows
1010
- .flatMap((row) => [row.completion, row.name, row.value, row.text])
1011
- .filter((value): value is string => typeof value === "string")
1012
- .map((value) => value.replace(/=.*$/, "").trim())
1013
- .filter((value) => value.length > 0);
1014
- return [...new Set(names)].sort();
1015
- }
1016
-
1017
964
  function isKnownSingletonPath(path: string): boolean {
1018
965
  return ["/system/resource", "/system/identity"].includes(
1019
966
  path.replace(/\/$/, ""),
@@ -1122,10 +1069,6 @@ function parseOutputFormat(value: string): RetrieveOutputFormat {
1122
1069
  });
1123
1070
  }
1124
1071
 
1125
- function pathToInspectString(path: readonly string[]): string {
1126
- return path.join(",");
1127
- }
1128
-
1129
1072
  function countResultObjects(data: unknown): number {
1130
1073
  if (Array.isArray(data)) {
1131
1074
  return data.length;