@tikoci/centrs 0.1.6 → 0.1.8-next.0

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 (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. package/src/transfer.ts +234 -32
package/src/retrieve.ts CHANGED
@@ -5,18 +5,31 @@ import type {
5
5
  CommonSettingsMeta,
6
6
  SettingSource as CoreSettingSource,
7
7
  EnvelopeValidationMeta,
8
+ ValidationTrace,
8
9
  } from "./core/envelope.ts";
10
+ import { notReachedReason } from "./core/envelope.ts";
9
11
  import {
10
12
  extractCompletionNames,
13
+ inspectArgumentNames,
11
14
  inspectChildren,
12
15
  inspectChildrenOrEmpty,
13
16
  inspectCompletions,
17
+ inspectWhereBooleans,
14
18
  isArgumentNode,
15
19
  isCommandNode,
16
20
  pathTokens,
17
21
  } from "./core/inspect.ts";
22
+ import {
23
+ compileQueryWords,
24
+ type ParsedQuery,
25
+ parseQueries,
26
+ } from "./core/query.ts";
18
27
  import { toYaml } from "./core/yaml.ts";
19
- import { CentrsError, serializeCentrsError } from "./errors.ts";
28
+ import {
29
+ CentrsError,
30
+ formatCentrsErrorText,
31
+ serializeCentrsError,
32
+ } from "./errors.ts";
20
33
  import {
21
34
  createProtocolAdapter,
22
35
  type ProtocolAdapter,
@@ -49,7 +62,12 @@ import {
49
62
  toCoreSource,
50
63
  } from "./resolver/index.ts";
51
64
 
52
- export const retrieveOutputFormats = ["text", "json", "yaml"] as const;
65
+ export const retrieveOutputFormats = [
66
+ "text",
67
+ "json",
68
+ "yaml",
69
+ "ndjson",
70
+ ] as const;
53
71
  export type RetrieveOutputFormat = (typeof retrieveOutputFormats)[number];
54
72
 
55
73
  export interface RetrieveRequest {
@@ -74,8 +92,10 @@ export interface RetrieveRequest {
74
92
  attributes?: string | readonly string[];
75
93
  allAttributes?: boolean;
76
94
  listAttributes?: boolean;
77
- filter?: string;
78
- query?: string;
95
+ /** Alias of `query`; both lists are AND-ed together. */
96
+ filter?: string | readonly string[];
97
+ /** Row filters written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`); several are AND-ed. */
98
+ query?: string | readonly string[];
79
99
  maxResultsBytes?: number;
80
100
  cdbFile?: string;
81
101
  cdbPassword?: string;
@@ -85,6 +105,22 @@ export interface RetrieveRequest {
85
105
  group?: string;
86
106
  /** Bounded worker-pool size for group fanout (defaults are transport-aware). */
87
107
  concurrency?: number;
108
+ /** Keep the menu's rows current over native-api `listen` (`retrieveFollow`). */
109
+ follow?: boolean;
110
+ /** `--follow` only: `.id` sweep period (`10s` default; `0` turns it off). */
111
+ sweep?: string | number;
112
+ /** Read the menu again every interval (`retrieveSample`), e.g. `5s`. */
113
+ sample?: string | number;
114
+ /** `--follow`: stop after this many `live` frames; `--sample`: after this many samples. */
115
+ count?: number;
116
+ /** `--follow`/`--sample`: wall-clock bound counted from the end of validation (e.g. `30s`). */
117
+ duration?: string | number;
118
+ /** Overall readiness/condition deadline; consumed by `retrieveWait()`. */
119
+ wait?: string | number;
120
+ /** Same grammar as `--query`: succeed when any selected row matches. */
121
+ until?: string | readonly string[];
122
+ /** Succeed on a completed read with zero selected rows. */
123
+ untilEmpty?: boolean;
88
124
  }
89
125
 
90
126
  export interface RetrieveWarning {
@@ -103,8 +139,116 @@ export interface RetrieveRequestSummary {
103
139
  timeoutMs: number;
104
140
  format: RetrieveOutputFormat;
105
141
  maxResultsBytes?: number;
142
+ /** The `--query` expressions, as given. */
143
+ query?: readonly string[];
144
+ /** Present when the request is a `--follow`. */
145
+ follow?: RetrieveFollowSettings;
146
+ /** Present when the request is a `--sample`. */
147
+ sample?: RetrieveSampleSettings;
148
+ wait?: RetrieveWaitSettings;
149
+ }
150
+
151
+ export interface RetrieveWaitSettings {
152
+ deadlineMs: number;
153
+ intervalMs: number;
154
+ until: readonly string[];
155
+ untilEmpty: boolean;
156
+ }
157
+
158
+ export interface RetrieveWaitMeta {
159
+ stopReason:
160
+ | "ready"
161
+ | "condition-met"
162
+ | "deadline-elapsed"
163
+ | "interrupted"
164
+ | "failed";
165
+ elapsedMs: number;
166
+ attempts: number;
167
+ observations: number;
168
+ }
169
+
170
+ /** The resolved cadence and bounds of a `--sample`. */
171
+ export interface RetrieveSampleSettings {
172
+ /** Time between the starts of two reads; a slow read delays the next one. */
173
+ intervalMs: number;
174
+ count?: number;
175
+ durationMs?: number;
176
+ }
177
+
178
+ /** The resolved bounds of a `--follow`. */
179
+ export interface RetrieveFollowSettings {
180
+ /** `.id` sweep period; `0` = off. */
181
+ sweepMs: number;
182
+ count?: number;
183
+ durationMs?: number;
184
+ }
185
+
186
+ /** Why a `--follow` ended. */
187
+ export type RetrieveFollowStopReason =
188
+ | "completed"
189
+ | "routeros-error"
190
+ | "count-reached"
191
+ | "duration-elapsed"
192
+ | "interrupted"
193
+ | "transport-error";
194
+
195
+ /** Counts carried by a `--follow` summary (also its `data`). */
196
+ export interface RetrieveFollowCounts {
197
+ /** Every frame line, snapshot and live. */
198
+ frames: number;
199
+ /** Frames before `synced`. */
200
+ snapshot: number;
201
+ /** `live` frames after `synced`; `--count` counts these. */
202
+ changes: number;
203
+ /** Completed `.id` sweeps. */
204
+ sweeps: number;
205
+ /** Whether the bootstrap finished before the follow ended. */
206
+ synced: boolean;
207
+ }
208
+
209
+ export interface RetrieveFollowSummary extends RetrieveFollowCounts {
210
+ stopReason: RetrieveFollowStopReason;
211
+ durationMs: number;
212
+ }
213
+
214
+ /** Why a `--sample` ended: it never completes on its own. */
215
+ export type RetrieveSampleStopReason = Exclude<
216
+ RetrieveFollowStopReason,
217
+ "completed"
218
+ >;
219
+
220
+ /** A `--sample` summary (also its `data`). */
221
+ export interface RetrieveSampleSummary {
222
+ stopReason: RetrieveSampleStopReason;
223
+ /** Sample lines emitted; a read still in flight at the stop is not one. */
224
+ samples: number;
225
+ durationMs: number;
106
226
  }
107
227
 
228
+ /**
229
+ * Per-line marker on `--follow` and `--sample` output
230
+ * (`commands/retrieve/README.md` → Follow, Sample). A `frame` is one row
231
+ * change: `upsert` carries the latest full row, `removed` carries
232
+ * `data: null`; `id` is the row identity even when the projection leaves `.id`
233
+ * out of `data`. A `sample` is one complete read; `at` is when it was sent
234
+ * (host clock) and `readMs` how long it took. The two summaries are told apart
235
+ * by their counts (`frames` vs `samples`).
236
+ */
237
+ export type RetrieveStreamMeta =
238
+ | { kind: "notice" }
239
+ | {
240
+ kind: "frame";
241
+ index: number;
242
+ phase: "snapshot" | "live";
243
+ change: "upsert" | "removed";
244
+ id: string;
245
+ source: "print" | "listen" | "sweep" | "membership";
246
+ }
247
+ | { kind: "synced"; rows: number }
248
+ | { kind: "sample"; index: number; at: string; readMs: number }
249
+ | ({ kind: "summary" } & RetrieveFollowSummary)
250
+ | ({ kind: "summary" } & RetrieveSampleSummary);
251
+
108
252
  export interface RetrieveOperationMeta {
109
253
  kind: "attributes" | "data";
110
254
  objectCount: number;
@@ -113,6 +257,10 @@ export interface RetrieveOperationMeta {
113
257
  username?: string;
114
258
  passwordProvided: boolean;
115
259
  };
260
+ /** Present only on `--follow` and `--sample` output. */
261
+ stream?: RetrieveStreamMeta;
262
+ /** The bounded waiter outcome; a watch duration never satisfies a condition. */
263
+ wait?: RetrieveWaitMeta;
116
264
  }
117
265
 
118
266
  export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
@@ -122,8 +270,9 @@ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
122
270
  >;
123
271
  export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
124
272
 
125
- interface RetrieveInspection {
273
+ export interface RetrieveInspection {
126
274
  command: "get" | "print";
275
+ singleton: boolean;
127
276
  }
128
277
 
129
278
  export interface ResolvedRetrieveRequest {
@@ -147,15 +296,108 @@ export interface ResolvedRetrieveRequest {
147
296
  allAttributes: boolean;
148
297
  listAttributes: boolean;
149
298
  verbose: boolean;
299
+ /** `--query`, parsed offline; compiled to words once the device says which bare names are booleans. */
300
+ query?: ResolvedRetrieveQuery;
301
+ follow?: RetrieveFollowSettings;
302
+ sample?: RetrieveSampleSettings;
303
+ wait?: RetrieveWaitSettings;
150
304
  warnings: readonly RetrieveWarning[];
151
305
  }
152
306
 
307
+ export interface ResolvedRetrieveQuery {
308
+ expressions: readonly string[];
309
+ parsed: ParsedQuery;
310
+ }
311
+
312
+ /**
313
+ * What a data read needs after validation: the path's shape (undefined under
314
+ * `--validate=false`) and the compiled `--query` words.
315
+ */
316
+ export interface RetrieveReadPlan {
317
+ inspection: RetrieveInspection | undefined;
318
+ query?: readonly string[];
319
+ }
320
+
321
+ export interface RetrieveOptions {
322
+ /**
323
+ * Called once resolution succeeds, before any connection. A caller that
324
+ * catches the later throw uses it to keep the resolved target, via and
325
+ * setting sources in its error envelope.
326
+ */
327
+ onResolved?: (resolved: ResolvedRetrieveRequest) => void;
328
+ /**
329
+ * Receives the gate's result the moment validation settles, so an error
330
+ * envelope built after a later throw reports the validation that ran.
331
+ */
332
+ trace?: ValidationTrace;
333
+ }
334
+
153
335
  export async function retrieve(
154
336
  request: RetrieveRequest,
155
337
  env: Record<string, string | undefined> = Bun.env,
338
+ options: RetrieveOptions = {},
156
339
  ): Promise<RetrieveSuccessEnvelope> {
340
+ if (request.wait !== undefined) {
341
+ throw new CentrsError({
342
+ code: "input/invalid-command",
343
+ summary:
344
+ "Use `retrieveWait()` for a bounded readiness or condition wait.",
345
+ remediation:
346
+ "Call `retrieveWait(request)` (library) or `centrs retrieve … --wait 30s` (CLI); inspect its success or failure envelope.",
347
+ });
348
+ }
349
+ if (request.follow) {
350
+ throw new CentrsError({
351
+ code: "input/invalid-command",
352
+ summary:
353
+ "`follow` yields a stream of envelopes instead of one `retrieve` result.",
354
+ remediation:
355
+ "Consume it with `retrieveFollow()` (library) or `centrs retrieve … --follow` (CLI): snapshot frames, `synced`, live frames, then a summary.",
356
+ context: { capability: "follow" },
357
+ });
358
+ }
359
+ if (request.sample !== undefined) {
360
+ throw new CentrsError({
361
+ code: "input/invalid-command",
362
+ summary:
363
+ "`sample` yields a stream of envelopes instead of one `retrieve` result.",
364
+ remediation:
365
+ "Consume it with `retrieveSample()` (library) or `centrs retrieve … --sample <interval>` (CLI): one line per complete read, then a summary.",
366
+ context: { capability: "sample" },
367
+ });
368
+ }
157
369
  const resolved = await resolveRetrieveRequest(request, env);
158
- return runResolvedRetrieve(resolved);
370
+ options.onResolved?.(resolved);
371
+ return runResolvedRetrieve(resolved, options.trace);
372
+ }
373
+
374
+ /**
375
+ * {@link retrieve} as an envelope that never throws. A failure after resolution
376
+ * carries the resolved target, via and setting sources
377
+ * ({@link buildRetrieveErrorEnvelopeFromResolved}); a failure before it keeps
378
+ * only the caller's input ({@link buildRetrieveErrorEnvelope}).
379
+ */
380
+ export async function retrieveEnvelope(
381
+ request: RetrieveRequest,
382
+ env: Record<string, string | undefined> = Bun.env,
383
+ options: RetrieveOptions = {},
384
+ ): Promise<RetrieveEnvelope> {
385
+ let resolved: ResolvedRetrieveRequest | undefined;
386
+ const trace = options.trace ?? {};
387
+ try {
388
+ return await retrieve(request, env, {
389
+ ...options,
390
+ trace,
391
+ onResolved: (value) => {
392
+ resolved = value;
393
+ options.onResolved?.(value);
394
+ },
395
+ });
396
+ } catch (error) {
397
+ return resolved
398
+ ? buildRetrieveErrorEnvelopeFromResolved(resolved, error, trace)
399
+ : buildRetrieveErrorEnvelope(request, error);
400
+ }
159
401
  }
160
402
 
161
403
  /**
@@ -167,10 +409,9 @@ export async function retrieve(
167
409
  */
168
410
  export async function runResolvedRetrieve(
169
411
  resolved: ResolvedRetrieveRequest,
412
+ trace: ValidationTrace = {},
170
413
  ): Promise<RetrieveSuccessEnvelope> {
171
414
  const warnings: RetrieveWarning[] = [...resolved.warnings];
172
- let availableAttributes: string[] | undefined;
173
- let inspection: RetrieveInspection | undefined;
174
415
  const backend = createProtocolAdapter({
175
416
  protocol: resolved.via.value,
176
417
  host: resolved.target.host,
@@ -184,78 +425,46 @@ export async function runResolvedRetrieve(
184
425
  });
185
426
 
186
427
  try {
187
- if (resolved.listAttributes || resolved.validate.value) {
188
- inspection = await inspectRetrievePath(resolved, backend);
189
- }
190
-
191
- if (
192
- resolved.listAttributes ||
193
- (resolved.validate.value && resolved.attributes.length > 0)
194
- ) {
195
- availableAttributes = await inspectAttributes(
428
+ if (resolved.listAttributes) {
429
+ const availableAttributes = await inspectAttributes(
196
430
  resolved,
197
- inspection ?? (await inspectRetrievePath(resolved, backend)),
431
+ await inspectRetrievePath(resolved, backend),
198
432
  backend,
199
433
  );
200
- }
201
-
202
- if (resolved.listAttributes) {
434
+ const validation: EnvelopeValidationMeta = {
435
+ enabled: true,
436
+ source: "live /console/inspect request=child+completion",
437
+ result: "passed",
438
+ availableAttributes,
439
+ };
440
+ trace.validation = validation;
203
441
  const envelope = buildSuccessEnvelope(
204
442
  resolved,
205
443
  {
206
444
  kind: "attributes",
207
- data: availableAttributes ?? [],
208
- },
209
- {
210
- enabled: true,
211
- source: "live /console/inspect request=child+completion",
212
- availableAttributes: availableAttributes ?? [],
445
+ data: availableAttributes,
213
446
  },
447
+ validation,
214
448
  warnings,
215
449
  );
216
450
  return applyMaxResultsBudget(envelope);
217
451
  }
218
452
 
219
- if (
220
- resolved.validate.value &&
221
- availableAttributes &&
222
- resolved.attributes.length > 0
223
- ) {
224
- const missing = resolved.attributes.filter(
225
- (attribute) => !availableAttributes?.includes(attribute),
226
- );
227
- if (missing.length > 0) {
228
- throw new CentrsError({
229
- code: "validation/unknown-attribute",
230
- summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
231
- remediation:
232
- "Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
233
- context: {
234
- path: resolved.path,
235
- parameter: missing[0],
236
- requestedAttributes: resolved.attributes,
237
- availableAttributes,
238
- },
239
- });
240
- }
241
- }
242
-
243
- const data = await executeRetrieve(resolved, backend);
453
+ const { validation, ...plan } = await validateRetrieveRead(
454
+ resolved,
455
+ backend,
456
+ );
457
+ // Recorded BEFORE the read, so a RouterOS fault or transport drop below
458
+ // reports the gate that passed rather than a guess (GH#435).
459
+ trace.validation = validation;
460
+ const data = await executeRetrieve(resolved, backend, plan);
244
461
  const envelope = buildSuccessEnvelope(
245
462
  resolved,
246
463
  {
247
464
  kind: "data",
248
465
  data,
249
466
  },
250
- {
251
- enabled: resolved.validate.value,
252
- source: resolved.validate.value
253
- ? availableAttributes
254
- ? "live /console/inspect request=child+completion"
255
- : "live /console/inspect request=child"
256
- : "disabled",
257
- availableAttributes,
258
- },
467
+ validation,
259
468
  warnings,
260
469
  );
261
470
  return applyMaxResultsBudget(envelope);
@@ -264,6 +473,53 @@ export async function runResolvedRetrieve(
264
473
  }
265
474
  }
266
475
 
476
+ /**
477
+ * The inspect half of a data read: the path's shape (singleton or list) and
478
+ * the requested attributes, checked once. `--validate=false` skips both.
479
+ * Shared by one-shot retrieve and `--sample`, which reads many times after it.
480
+ */
481
+ export async function validateRetrieveRead(
482
+ resolved: ResolvedRetrieveRequest,
483
+ backend: ProtocolAdapter,
484
+ ): Promise<RetrieveReadPlan & { validation: EnvelopeValidationMeta }> {
485
+ if (!resolved.validate.value) {
486
+ return {
487
+ inspection: undefined,
488
+ query: compileQueryWithoutValidation(resolved),
489
+ validation: { enabled: false, source: "disabled" },
490
+ };
491
+ }
492
+ const inspection = await inspectRetrievePath(resolved, backend);
493
+ if (resolved.query && inspection.singleton) {
494
+ throw singletonQueryError(resolved, resolved.query);
495
+ }
496
+ const availableAttributes =
497
+ resolved.attributes.length > 0 || resolved.query !== undefined
498
+ ? await assertKnownAttributes(resolved, inspection, backend)
499
+ : undefined;
500
+ const query =
501
+ resolved.query && availableAttributes
502
+ ? await compileValidatedQuery(
503
+ resolved,
504
+ resolved.query,
505
+ availableAttributes,
506
+ backend,
507
+ )
508
+ : undefined;
509
+ return {
510
+ inspection,
511
+ query,
512
+ validation: {
513
+ enabled: true,
514
+ source: availableAttributes
515
+ ? "live /console/inspect request=child+completion"
516
+ : "live /console/inspect request=child",
517
+ result: "passed",
518
+ availableAttributes,
519
+ },
520
+ };
521
+ }
522
+
267
523
  export function buildRetrieveErrorEnvelope(
268
524
  request: RetrieveRequest,
269
525
  error: unknown,
@@ -308,6 +564,7 @@ export function buildRetrieveErrorEnvelope(
308
564
  export function buildRetrieveErrorEnvelopeFromResolved(
309
565
  resolved: ResolvedRetrieveRequest,
310
566
  error: unknown,
567
+ trace?: ValidationTrace,
311
568
  ): RetrieveErrorEnvelope {
312
569
  const centrsError =
313
570
  error instanceof CentrsError
@@ -320,11 +577,10 @@ export function buildRetrieveErrorEnvelopeFromResolved(
320
577
  cause: error,
321
578
  });
322
579
 
323
- const meta = metaFromResolved(resolved, {
324
- enabled: resolved.validate.value,
325
- source: resolved.validate.value ? "live /console/inspect" : "disabled",
326
- result: "failed",
327
- });
580
+ const meta = metaFromResolved(
581
+ resolved,
582
+ trace?.validation ?? unsettledValidationMeta(resolved, centrsError),
583
+ );
328
584
 
329
585
  return {
330
586
  ok: false,
@@ -335,6 +591,53 @@ export function buildRetrieveErrorEnvelopeFromResolved(
335
591
  };
336
592
  }
337
593
 
594
+ const RETRIEVE_DEVICE_STAGE_SOURCE = "live /console/inspect";
595
+
596
+ /**
597
+ * `meta.validation` for a failure with no settled gate result: the gate
598
+ * refused, or something stopped it before it answered (GH#435).
599
+ *
600
+ * Every retrieve gate refusal is a `CentrsError` the gate itself raised from
601
+ * an inspect answer: `validation/*`, and the singleton `--query` refusal
602
+ * (`usage/conflicting-flags`). A `transport/*` or `auth/*` failure means the
603
+ * device never answered; a `routeros/*` error is a device answer that is not
604
+ * a verdict; `internal/*` is a centrs defect. Those four report no `result`.
605
+ */
606
+ function unsettledValidationMeta(
607
+ resolved: ResolvedRetrieveRequest,
608
+ error: CentrsError,
609
+ ): EnvelopeValidationMeta {
610
+ if (!resolved.validate.value) {
611
+ return { enabled: false, source: "disabled" };
612
+ }
613
+ if (/^(transport|auth|routeros|internal)\//.test(error.code)) {
614
+ return {
615
+ enabled: true,
616
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
617
+ stages: [
618
+ {
619
+ stage: "device",
620
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
621
+ result: "skipped",
622
+ reason: notReachedReason(error.code),
623
+ },
624
+ ],
625
+ };
626
+ }
627
+ return {
628
+ enabled: true,
629
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
630
+ result: "failed",
631
+ stages: [
632
+ {
633
+ stage: "device",
634
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
635
+ result: "failed",
636
+ },
637
+ ],
638
+ };
639
+ }
640
+
338
641
  export function renderRetrieveEnvelope(
339
642
  envelope: RetrieveEnvelope,
340
643
  format: RetrieveOutputFormat,
@@ -345,10 +648,17 @@ export function renderRetrieveEnvelope(
345
648
  return JSON.stringify(envelope, null, 2);
346
649
  case "yaml":
347
650
  return toYaml(envelope);
651
+ case "ndjson":
652
+ return JSON.stringify(envelope);
348
653
  case "text":
349
- return envelope.ok
350
- ? renderRetrieveSuccessText(envelope, options)
351
- : renderRetrieveErrorText(envelope, options);
654
+ return (
655
+ (envelope.ok
656
+ ? renderRetrieveSuccessText(envelope, options)
657
+ : formatCentrsErrorText(envelope.error, options)) +
658
+ (envelope.meta.operation?.wait
659
+ ? `\nwait: ${envelope.meta.operation.wait.stopReason}; ${envelope.meta.operation.wait.elapsedMs}ms; ${envelope.meta.operation.wait.attempts} attempt(s), ${envelope.meta.operation.wait.observations} observation(s)`
660
+ : "")
661
+ );
352
662
  default:
353
663
  return exhaustiveOutputFormat(format);
354
664
  }
@@ -414,26 +724,7 @@ function renderRetrieveSuccessText(
414
724
  return lines.join("\n");
415
725
  }
416
726
 
417
- function renderRetrieveErrorText(
418
- envelope: RetrieveErrorEnvelope,
419
- options: { verbose?: boolean },
420
- ): string {
421
- const { error } = envelope;
422
- const lines = [`[${error.code}] ${error.summary}`];
423
-
424
- if (error.remediation) {
425
- lines.push(`Fix: ${error.remediation}`);
426
- }
427
-
428
- if (options.verbose && error.context) {
429
- lines.push("");
430
- lines.push(JSON.stringify(error.context, null, 2));
431
- }
432
-
433
- return lines.join("\n");
434
- }
435
-
436
- function retrieveRequestSummary(
727
+ export function retrieveRequestSummary(
437
728
  resolved: ResolvedRetrieveRequest,
438
729
  ): RetrieveRequestSummary {
439
730
  return {
@@ -446,6 +737,10 @@ function retrieveRequestSummary(
446
737
  timeoutMs: resolved.timeoutMs.value,
447
738
  format: resolved.format.value,
448
739
  maxResultsBytes: resolved.maxResultsBytes?.value,
740
+ ...(resolved.query ? { query: resolved.query.expressions } : {}),
741
+ ...(resolved.follow ? { follow: resolved.follow } : {}),
742
+ ...(resolved.sample ? { sample: resolved.sample } : {}),
743
+ ...(resolved.wait ? { wait: resolved.wait } : {}),
449
744
  };
450
745
  }
451
746
 
@@ -453,7 +748,7 @@ function retrieveRequestSummary(
453
748
  * Common `target` / `via` / `settings` meta for a resolved target. Shared by
454
749
  * success and per-target error envelopes so both carry identical provenance.
455
750
  */
456
- function metaFromResolved(
751
+ export function metaFromResolved(
457
752
  resolved: ResolvedRetrieveRequest,
458
753
  validation: EnvelopeValidationMeta,
459
754
  operation?: RetrieveOperationMeta,
@@ -511,7 +806,7 @@ function metaFromResolved(
511
806
  };
512
807
  }
513
808
 
514
- function buildSuccessEnvelope(
809
+ export function buildSuccessEnvelope(
515
810
  resolved: ResolvedRetrieveRequest,
516
811
  result: { kind: "attributes" | "data"; data: unknown },
517
812
  validation: EnvelopeValidationMeta,
@@ -536,7 +831,7 @@ function buildSuccessEnvelope(
536
831
  };
537
832
  }
538
833
 
539
- function applyMaxResultsBudget(
834
+ export function applyMaxResultsBudget(
540
835
  envelope: RetrieveSuccessEnvelope,
541
836
  ): RetrieveSuccessEnvelope {
542
837
  const operation = envelope.meta.operation;
@@ -589,20 +884,20 @@ export function validateRetrieveRequestShape(
589
884
  });
590
885
  }
591
886
 
592
- if (request.filter !== undefined || request.query !== undefined) {
887
+ const query = resolveRetrieveQuery(request);
888
+ resolveWaitSettings(request);
889
+ if (query && request.listAttributes) {
593
890
  throw new CentrsError({
594
- code: "validation/not-implemented",
595
- summary:
596
- "`--filter` and `--query` are not implemented yet for `retrieve`.",
891
+ code: "usage/conflicting-flags",
892
+ summary: "`--list-attributes` cannot be combined with `--query`.",
597
893
  remediation:
598
- "Remove the flag for now, or narrow the request with `--attribute` and `--max-results` instead.",
599
- context: {
600
- filter: request.filter,
601
- query: request.query,
602
- },
894
+ "Run `--list-attributes` by itself to see the property names, then filter with `--query`.",
895
+ context: { flag: "--query" },
603
896
  });
604
897
  }
605
898
 
899
+ assertStreamShape(request);
900
+
606
901
  const attributeSelections = normalizeAttributeSelection(request);
607
902
  if (request.allAttributes && attributeSelections.length > 0) {
608
903
  throw new CentrsError({
@@ -630,6 +925,195 @@ export function validateRetrieveRequestShape(
630
925
  return attributeSelections;
631
926
  }
632
927
 
928
+ /**
929
+ * `--follow`/`--sample` flag pairings, checked before any network work: the
930
+ * two are exclusive, their bounds need one of them, and a byte budget or an
931
+ * attribute listing has no meaning on a stream.
932
+ */
933
+ function assertStreamShape(request: RetrieveRequest): void {
934
+ const sample = request.sample !== undefined && request.wait === undefined;
935
+ if (request.follow && sample) {
936
+ throw new CentrsError({
937
+ code: "usage/conflicting-flags",
938
+ summary: "`--follow` cannot be combined with `--sample`.",
939
+ remediation:
940
+ "Use `--follow` for object changes RouterOS announces, or `--sample <interval>` to read the menu on a timer (counters, state `listen` never reports).",
941
+ context: { flag: "--sample" },
942
+ });
943
+ }
944
+ if (!request.follow) {
945
+ const orphan = (
946
+ [
947
+ ["--sweep", request.sweep],
948
+ ["--count", sample ? undefined : request.count],
949
+ ["--duration", sample ? undefined : request.duration],
950
+ ] as const
951
+ ).find(([, value]) => value !== undefined);
952
+ if (orphan) {
953
+ const followOnly = orphan[0] === "--sweep";
954
+ throw new CentrsError({
955
+ code: "usage/conflicting-flags",
956
+ summary: `\`${orphan[0]}\` only applies to ${followOnly ? "`--follow`" : "`--follow` or `--sample`"}.`,
957
+ remediation: followOnly
958
+ ? "Add `--follow` to follow the menu, or drop `--sweep`."
959
+ : `Add \`--follow\` or \`--sample <interval>\`, or drop \`${orphan[0]}\` for a one-shot read.`,
960
+ context: { flag: orphan[0] },
961
+ });
962
+ }
963
+ if (!sample) return;
964
+ }
965
+ const mode = request.follow ? "--follow" : "--sample";
966
+ const conflict = request.listAttributes
967
+ ? "--list-attributes"
968
+ : request.maxResultsBytes !== undefined
969
+ ? "--max-results"
970
+ : undefined;
971
+ if (conflict) {
972
+ throw new CentrsError({
973
+ code: "usage/conflicting-flags",
974
+ summary: `\`${mode}\` cannot be combined with \`${conflict}\`.`,
975
+ remediation: `Run \`${conflict}\` as a one-shot read, then use \`${mode}\` without it.`,
976
+ context: { flag: conflict },
977
+ });
978
+ }
979
+ if (
980
+ request.count !== undefined &&
981
+ (!Number.isInteger(request.count) || request.count < 1)
982
+ ) {
983
+ throw new CentrsError({
984
+ code: "settings/invalid-integer",
985
+ summary: `\`--count\` must be a positive integer. Received: ${request.count}`,
986
+ remediation: request.follow
987
+ ? "Pass how many live changes to wait for, e.g. `--count 1` for the next change."
988
+ : "Pass how many samples to take, e.g. `--count 3`.",
989
+ context: { flag: "--count", count: request.count },
990
+ });
991
+ }
992
+ }
993
+
994
+ function resolveWaitSettings(
995
+ request: RetrieveRequest,
996
+ ): RetrieveWaitSettings | undefined {
997
+ const until = request.until === undefined ? [] : [request.until].flat();
998
+ if (until.length > 0) parseQueries(until, "--until");
999
+ if (request.wait === undefined) {
1000
+ if (until.length > 0 || request.untilEmpty)
1001
+ throw new CentrsError({
1002
+ code: "usage/conflicting-flags",
1003
+ summary: "`--until` and `--until-empty` need a `--wait` deadline.",
1004
+ remediation:
1005
+ "Add an overall bound, e.g. `--wait 2m`, so the condition cannot wait forever.",
1006
+ });
1007
+ return undefined;
1008
+ }
1009
+ const conflict = request.follow
1010
+ ? "--follow"
1011
+ : request.count !== undefined
1012
+ ? "--count"
1013
+ : request.duration !== undefined
1014
+ ? "--duration"
1015
+ : request.listAttributes
1016
+ ? "--list-attributes"
1017
+ : until.length > 0 && request.untilEmpty
1018
+ ? "--until-empty"
1019
+ : undefined;
1020
+ if (conflict)
1021
+ throw new CentrsError({
1022
+ code: "usage/conflicting-flags",
1023
+ summary: `\`--wait\` cannot be combined with \`${conflict}\`.`,
1024
+ remediation:
1025
+ "Use `--wait` for a readiness/condition deadline, `--sample` for its polling cadence, and either `--until` or `--until-empty` for a condition.",
1026
+ });
1027
+ // A unit is required for the same reason as `--sample`: a bare `30` would
1028
+ // be a 30 ms deadline, which expires before validation completes.
1029
+ const text = String(request.wait).trim();
1030
+ const deadlineMs = /^\d+$/.test(text) ? -1 : parseDuration(text);
1031
+ if (deadlineMs <= 0)
1032
+ throw new CentrsError({
1033
+ code: "settings/invalid-timeout",
1034
+ summary: `\`--wait\` needs a positive deadline with a unit. Received: ${text}`,
1035
+ remediation:
1036
+ "Pass an overall duration such as `--wait 30s` or `--wait 5m`.",
1037
+ context: { flag: "--wait", value: text },
1038
+ });
1039
+ return {
1040
+ deadlineMs,
1041
+ intervalMs:
1042
+ request.sample === undefined
1043
+ ? 1_000
1044
+ : parseStreamInterval("--sample", request.sample),
1045
+ until,
1046
+ untilEmpty: request.untilEmpty ?? false,
1047
+ };
1048
+ }
1049
+
1050
+ function resolveFollowSettings(
1051
+ request: RetrieveRequest,
1052
+ ): RetrieveFollowSettings | undefined {
1053
+ if (!request.follow) return undefined;
1054
+ const durationMs =
1055
+ request.duration === undefined
1056
+ ? undefined
1057
+ : parseDuration(String(request.duration));
1058
+ return {
1059
+ sweepMs:
1060
+ request.sweep === undefined
1061
+ ? FOLLOW_SWEEP_DEFAULT_MS
1062
+ : parseStreamInterval("--sweep", request.sweep),
1063
+ ...(request.count !== undefined ? { count: request.count } : {}),
1064
+ ...(durationMs !== undefined ? { durationMs } : {}),
1065
+ };
1066
+ }
1067
+
1068
+ /** `--sweep` default (#396 slice decisions, 2026-10-08). */
1069
+ export const FOLLOW_SWEEP_DEFAULT_MS = 10_000;
1070
+
1071
+ function resolveSampleSettings(
1072
+ request: RetrieveRequest,
1073
+ ): RetrieveSampleSettings | undefined {
1074
+ if (request.sample === undefined || request.wait !== undefined)
1075
+ return undefined;
1076
+ const durationMs =
1077
+ request.duration === undefined
1078
+ ? undefined
1079
+ : parseDuration(String(request.duration));
1080
+ return {
1081
+ intervalMs: parseStreamInterval("--sample", request.sample),
1082
+ ...(request.count !== undefined ? { count: request.count } : {}),
1083
+ ...(durationMs !== undefined ? { durationMs } : {}),
1084
+ };
1085
+ }
1086
+
1087
+ /**
1088
+ * A `--sample` or `--sweep` period needs a unit: RouterOS reads a bare
1089
+ * `interval=5` as seconds while centrs durations read a bare number as
1090
+ * milliseconds, so `--sample 5` is refused rather than guessed. `--sweep 0`
1091
+ * (off) is the one bare number allowed; `--sample` must be above zero.
1092
+ */
1093
+ function parseStreamInterval(
1094
+ flag: "--sample" | "--sweep",
1095
+ value: string | number,
1096
+ ): number {
1097
+ const text = String(value).trim();
1098
+ const off = flag === "--sweep" && text === "0";
1099
+ const intervalMs = off ? 0 : /^\d+$/.test(text) ? -1 : parseDuration(text);
1100
+ if (intervalMs < 0 || (flag === "--sample" && intervalMs === 0)) {
1101
+ throw new CentrsError({
1102
+ code: "settings/invalid-timeout",
1103
+ summary:
1104
+ flag === "--sample"
1105
+ ? `\`--sample\` needs a positive interval with a unit. Received: ${text}`
1106
+ : `\`--sweep\` needs a unit (or \`0\` to turn it off). Received: ${text}`,
1107
+ remediation:
1108
+ flag === "--sample"
1109
+ ? "Pass the time between reads with its unit, e.g. `--sample 5s` or `--sample 500ms`."
1110
+ : "Pass the sweep period with its unit, e.g. `--sweep 10s` or `--sweep 500ms`, or `--sweep 0` to turn it off.",
1111
+ context: { flag, value: text },
1112
+ });
1113
+ }
1114
+ return intervalMs;
1115
+ }
1116
+
633
1117
  /**
634
1118
  * Build a {@link ResolvedRetrieveRequest} from the static request plus an
635
1119
  * already-resolved CDB record (or `undefined` for a literal target). Fanout
@@ -723,6 +1207,10 @@ export function buildResolvedRetrieve(
723
1207
  allAttributes: request.allAttributes ?? false,
724
1208
  listAttributes: request.listAttributes ?? false,
725
1209
  verbose: request.verbose ?? false,
1210
+ query: resolveRetrieveQuery(request),
1211
+ follow: resolveFollowSettings(request),
1212
+ sample: resolveSampleSettings(request),
1213
+ wait: resolveWaitSettings(request),
726
1214
  warnings: connection
727
1215
  ? [...connection.warnings]
728
1216
  : (cdbResolution?.warnings ?? []),
@@ -786,6 +1274,9 @@ export function resolveRetrieveGlobalContext(
786
1274
  timeoutMs: timeoutMs.value,
787
1275
  format: format.value,
788
1276
  maxResultsBytes: maxResultsBytes?.value,
1277
+ ...(queryExpressions(request).length > 0
1278
+ ? { query: queryExpressions(request) }
1279
+ : {}),
789
1280
  },
790
1281
  settings: {
791
1282
  via: toCoreSource(via.source),
@@ -883,11 +1374,13 @@ function resolveProtocol(
883
1374
  cdb?: CdbResolution,
884
1375
  config: Record<string, string | undefined> = {},
885
1376
  ): ResolvedSetting<RouterOsProtocol> {
1377
+ // A follow infers native-api when `via` is unset (REST cannot follow); a
1378
+ // `via` from any source still wins, and `retrieveFollow` rejects non-native.
886
1379
  const via = resolveStringSetting(
887
1380
  request.via,
888
1381
  env,
889
1382
  "CENTRS_VIA",
890
- "rest-api",
1383
+ request.follow ? "native-api" : "rest-api",
891
1384
  "via",
892
1385
  undefined,
893
1386
  cdb?.overrides.via,
@@ -972,7 +1465,7 @@ function resolveFormat(
972
1465
  * trap. Attribute/completion discovery does NOT use the swallowing probe — traps
973
1466
  * there surface as-is.
974
1467
  */
975
- async function inspectRetrievePath(
1468
+ export async function inspectRetrievePath(
976
1469
  resolved: ResolvedRetrieveRequest,
977
1470
  backend: ProtocolAdapter,
978
1471
  ): Promise<RetrieveInspection> {
@@ -999,12 +1492,145 @@ async function inspectRetrievePath(
999
1492
  });
1000
1493
  }
1001
1494
 
1002
- const singleton = isKnownSingletonPath(resolved.path);
1495
+ // A singleton's `get` takes only `value-name`; a list menu's also takes
1496
+ // `number` (CHR 7.23.7: /tool/romon, /ip/dns, /system/identity vs
1497
+ // /ip/address). A hardcoded path list missed every other singleton and
1498
+ // validated its properties against `print`'s flags (issue #377).
1499
+ const singleton =
1500
+ supportsGet &&
1501
+ !(await inspectArgumentNames(backend, [...tokens, "get"])).includes(
1502
+ "number",
1503
+ );
1003
1504
  return {
1004
- command: singleton && supportsGet ? "get" : "print",
1505
+ command: singleton ? "get" : "print",
1506
+ singleton,
1005
1507
  };
1006
1508
  }
1007
1509
 
1510
+ /**
1511
+ * Inspect the menu's attribute names and reject requested ones it lacks with
1512
+ * `validation/unknown-attribute`. Shared by one-shot retrieve and `--follow`.
1513
+ */
1514
+ export async function assertKnownAttributes(
1515
+ resolved: ResolvedRetrieveRequest,
1516
+ inspection: RetrieveInspection,
1517
+ backend: ProtocolAdapter,
1518
+ ): Promise<string[]> {
1519
+ const availableAttributes = await inspectAttributes(
1520
+ resolved,
1521
+ inspection,
1522
+ backend,
1523
+ );
1524
+ const missing = resolved.attributes.filter(
1525
+ (attribute) => !availableAttributes.includes(attribute),
1526
+ );
1527
+ if (missing.length > 0) {
1528
+ throw new CentrsError({
1529
+ code: "validation/unknown-attribute",
1530
+ summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
1531
+ remediation:
1532
+ "Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
1533
+ context: {
1534
+ path: resolved.path,
1535
+ parameter: missing[0],
1536
+ requestedAttributes: resolved.attributes,
1537
+ availableAttributes,
1538
+ },
1539
+ });
1540
+ }
1541
+ return availableAttributes;
1542
+ }
1543
+
1544
+ function queryExpressions(request: RetrieveRequest): string[] {
1545
+ return [request.filter, request.query].flatMap((value) =>
1546
+ value === undefined ? [] : [value].flat(),
1547
+ );
1548
+ }
1549
+
1550
+ /** Parse `--query`/`--filter` offline; a bad expression fails before any network work. */
1551
+ function resolveRetrieveQuery(
1552
+ request: RetrieveRequest,
1553
+ ): ResolvedRetrieveQuery | undefined {
1554
+ const expressions = queryExpressions(request);
1555
+ if (expressions.length === 0) return undefined;
1556
+ return { expressions, parsed: parseQueries(expressions) };
1557
+ }
1558
+
1559
+ /**
1560
+ * Validate the predicate's property names against the menu's attributes (the
1561
+ * device fails an unknown one silently: no rows, no trap) and ask it which bare
1562
+ * names are booleans, then compile.
1563
+ */
1564
+ async function compileValidatedQuery(
1565
+ resolved: ResolvedRetrieveRequest,
1566
+ query: ResolvedRetrieveQuery,
1567
+ availableAttributes: readonly string[],
1568
+ backend: ProtocolAdapter,
1569
+ ): Promise<string[]> {
1570
+ const known = new Set([...availableAttributes, ".id"]);
1571
+ const unknown = query.parsed.names.filter((name) => !known.has(name));
1572
+ if (unknown.length > 0) {
1573
+ throw new CentrsError({
1574
+ code: "validation/unknown-attribute",
1575
+ summary: `Unknown RouterOS attribute ${unknown.join(", ")} in --query for ${resolved.path}.`,
1576
+ remediation:
1577
+ "RouterOS answers a query on a misspelled property with no rows, not an error. Check the name with `--list-attributes`.",
1578
+ context: {
1579
+ path: resolved.path,
1580
+ parameter: unknown[0],
1581
+ flag: "--query",
1582
+ query: query.expressions,
1583
+ availableAttributes: [...known].sort(),
1584
+ },
1585
+ });
1586
+ }
1587
+ const booleans = await inspectWhereBooleans(
1588
+ backend,
1589
+ resolved.path,
1590
+ query.parsed.bareNames,
1591
+ );
1592
+ return compileQueryWords(query.parsed, booleans);
1593
+ }
1594
+
1595
+ /**
1596
+ * `--validate=false` skips inspect, so nothing can say whether a bare name is a
1597
+ * boolean (`disabled=yes`) or "is set"; refuse rather than guess.
1598
+ */
1599
+ function compileQueryWithoutValidation(
1600
+ resolved: ResolvedRetrieveRequest,
1601
+ ): string[] | undefined {
1602
+ const query = resolved.query;
1603
+ if (!query) return undefined;
1604
+ if (isKnownSingletonPath(resolved.path)) {
1605
+ throw singletonQueryError(resolved, query);
1606
+ }
1607
+ if (query.parsed.bareNames.length > 0) {
1608
+ throw new CentrsError({
1609
+ code: "input/invalid-query",
1610
+ summary: `A bare name in --query (${query.parsed.bareNames.join(", ")}) needs validation to learn whether it is a boolean.`,
1611
+ remediation:
1612
+ "Drop `--validate=false`, or write a boolean out as `disabled=yes`.",
1613
+ context: {
1614
+ query: query.expressions,
1615
+ bareNames: query.parsed.bareNames,
1616
+ },
1617
+ });
1618
+ }
1619
+ return compileQueryWords(query.parsed, new Set());
1620
+ }
1621
+
1622
+ function singletonQueryError(
1623
+ resolved: ResolvedRetrieveRequest,
1624
+ query: ResolvedRetrieveQuery,
1625
+ ): CentrsError {
1626
+ return new CentrsError({
1627
+ code: "usage/conflicting-flags",
1628
+ summary: `${resolved.path} is a single record, so --query has no rows to filter.`,
1629
+ remediation: `Read it with \`centrs retrieve <router> ${resolved.path}\`, using \`--attribute <name>\` for one value.`,
1630
+ context: { path: resolved.path, flag: "--query", query: query.expressions },
1631
+ });
1632
+ }
1633
+
1008
1634
  async function inspectAttributes(
1009
1635
  resolved: ResolvedRetrieveRequest,
1010
1636
  inspection: RetrieveInspection,
@@ -1037,12 +1663,15 @@ async function inspectAttributes(
1037
1663
  .sort();
1038
1664
  }
1039
1665
 
1040
- async function executeRetrieve(
1666
+ /** The data half of a read: one `get` (singleton) or `print` (list), projected. */
1667
+ export async function executeRetrieve(
1041
1668
  resolved: ResolvedRetrieveRequest,
1042
1669
  backend: ProtocolAdapter,
1670
+ plan: RetrieveReadPlan,
1671
+ signal?: AbortSignal,
1043
1672
  ): Promise<unknown> {
1044
- if (isKnownSingletonPath(resolved.path)) {
1045
- const data = await backend.getSingleton(resolved.path);
1673
+ if (plan.inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
1674
+ const data = await backend.getSingleton(resolved.path, { signal });
1046
1675
  if (resolved.attributes.length > 0) {
1047
1676
  return projectSingletonAttributes(data, resolved.attributes);
1048
1677
  }
@@ -1052,10 +1681,13 @@ async function executeRetrieve(
1052
1681
  return backend.list(resolved.path, {
1053
1682
  proplist: resolved.attributes.length > 0 ? resolved.attributes : undefined,
1054
1683
  detail: resolved.allAttributes,
1684
+ query: plan.query,
1685
+ signal,
1055
1686
  });
1056
1687
  }
1057
1688
 
1058
- function isKnownSingletonPath(path: string): boolean {
1689
+ /** Fallback for `--validate=false`, which skips the inspect that detects singletons. */
1690
+ export function isKnownSingletonPath(path: string): boolean {
1059
1691
  return ["/system/resource", "/system/identity"].includes(
1060
1692
  path.replace(/\/$/, ""),
1061
1693
  );