@tikoci/centrs 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  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 +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -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 +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. package/src/transfer.ts +234 -32
package/src/api.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * mechanics (id-in-URL vs `=.id=`, `.query` vs `?`-words) live in the adapter.
12
12
  *
13
13
  * Grounded RouterOS facts this depends on live in `commands/api/AGENTS.md`
14
- * (CHR 7.23.1). The `--listen` streaming path and multi-target fan-out are later
15
- * phases; this file is the single-target rest-api + native-api surface.
14
+ * (CHR 7.23.1 and command-stream examples). Streaming and fan-out reuse this
15
+ * structured validation, confirmation, and result-envelope surface.
16
16
  */
17
17
 
18
18
  import type {
@@ -27,16 +27,27 @@ import type {
27
27
  ValidationTrace,
28
28
  Warning,
29
29
  } from "./core/envelope.ts";
30
- import { buildTip } from "./core/envelope.ts";
30
+ import { buildTip, notReachedReason } from "./core/envelope.ts";
31
31
  import {
32
32
  inspectArgumentNames,
33
33
  inspectChildrenOrEmpty,
34
+ inspectWhereBooleans,
34
35
  isCommandNode,
35
36
  pathTokens,
36
37
  } from "./core/inspect.ts";
38
+ import {
39
+ compileQueryWords,
40
+ type ParsedQuery,
41
+ parseQueries,
42
+ } from "./core/query.ts";
37
43
  import { mapRouterOsResultError } from "./core/routeros-errors.ts";
38
44
  import { toYaml } from "./core/yaml.ts";
39
- import { CentrsError, serializeCentrsError } from "./errors.ts";
45
+ import {
46
+ CentrsError,
47
+ formatCentrsErrorLine,
48
+ formatCentrsErrorText,
49
+ serializeCentrsError,
50
+ } from "./errors.ts";
40
51
  import {
41
52
  promptForWriteConfirmation,
42
53
  validateRouterOsScript,
@@ -52,6 +63,7 @@ import {
52
63
  type ProtocolAdapter,
53
64
  type ProtocolApiRequest,
54
65
  type ProtocolApiResult,
66
+ type ProtocolStreamReply,
55
67
  plannedProtocols,
56
68
  type RouterOsProtocol,
57
69
  } from "./protocols/index.ts";
@@ -76,7 +88,7 @@ import {
76
88
  toCoreSource,
77
89
  } from "./resolver/index.ts";
78
90
 
79
- export const apiOutputFormats = ["json", "yaml", "text"] as const;
91
+ export const apiOutputFormats = ["json", "ndjson", "yaml", "text"] as const;
80
92
  export type ApiOutputFormat = (typeof apiOutputFormats)[number];
81
93
 
82
94
  /** HTTP methods `api` honors literally against RouterOS's REST verb map. */
@@ -101,7 +113,7 @@ export interface ApiRequest {
101
113
  data?: string;
102
114
  /** `--input` raw JSON body (file/stdin content already read). Mutually exclusive with `fields` / `data`. */
103
115
  inputBody?: string;
104
- /** `--query` / `--filter` expressions (`name=value`, `name!=value`, `name>value`, `name`). */
116
+ /** `--query` / `--filter` expressions, written as after `print where` (`src/core/query.ts`); several are AND-ed. */
105
117
  query?: readonly string[];
106
118
  /** `--raw-query` verbatim RouterOS query words (no leading `?`). */
107
119
  rawQuery?: readonly string[];
@@ -113,11 +125,15 @@ export interface ApiRequest {
113
125
  * error contract (#154).
114
126
  */
115
127
  raw?: boolean;
116
- /** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
117
- listen?: boolean;
118
- /** `--duration`: bound a `--listen` stream (later phase). */
128
+ /**
129
+ * `--stream`: deliver the literal command's native replies incrementally.
130
+ * It never changes the command: a GET streams one print. To follow changes,
131
+ * request the menu's `listen` (`ip/address/listen`), which implies it.
132
+ */
133
+ stream?: boolean;
134
+ /** `--duration`: bound a native reply stream. */
119
135
  duration?: string;
120
- /** `--count`: bound a `--listen` stream to N frames (later phase). */
136
+ /** `--count`: bound a native reply stream to N rows. */
121
137
  count?: number;
122
138
  via?: string;
123
139
  host?: string;
@@ -160,7 +176,7 @@ export interface NormalizedApiEndpoint {
160
176
  path: string;
161
177
  /** Trailing `*id` segment, when the endpoint addressed one row. */
162
178
  id?: string;
163
- /** True when the endpoint's terminal segment was `listen` (infers `--listen`). */
179
+ /** True when the endpoint's terminal segment was `listen`: the menu's `listen` command, which implies `--stream`. */
164
180
  listen: boolean;
165
181
  }
166
182
 
@@ -173,7 +189,10 @@ export interface ApiRequestSummary {
173
189
  /** `null` when the method was invalid and no verb could be mapped (error path). */
174
190
  verb: ApiVerb | null;
175
191
  write: boolean;
192
+ /** The request is the menu's `listen` command (a `/listen` endpoint). */
176
193
  listen: boolean;
194
+ /** Replies are delivered incrementally (`--stream`, or implied by `listen`). */
195
+ stream: boolean;
177
196
  yes: boolean;
178
197
  validate: boolean;
179
198
  raw: boolean;
@@ -182,20 +201,41 @@ export interface ApiRequestSummary {
182
201
  proplist?: readonly string[];
183
202
  }
184
203
 
185
- /** Why a `--stream` follow ended. */
204
+ /** Why an incremental native command stream ended. */
186
205
  export type ApiStreamStopReason =
206
+ | "completed"
207
+ | "routeros-error"
187
208
  | "count-reached"
188
209
  | "duration-elapsed"
189
210
  | "interrupted"
190
211
  | "transport-error";
191
212
 
192
- /** Per-line marker on `--stream` NDJSON output: a change frame, or the terminating summary. */
213
+ /**
214
+ * Per-line marker on `--stream` NDJSON output: a leading `notice`, one wire
215
+ * reply, or the terminating summary. A `notice` (`data: null`, not counted)
216
+ * comes first only when the request has advice in `tips` — what RouterOS will
217
+ * not report for it — so even a watch that never gets a reply sees it. A frame's `reply` is `re` (a row; `data` is the row) or
218
+ * `empty` (RouterOS `!empty`: zero rows; `data` is `null`). `afterStop` marks a
219
+ * reply that arrived after centrs stopped the stream locally, such as the
220
+ * `!empty` a cancelled listen sends; it is not evidence of an empty table.
221
+ */
193
222
  export type ApiStreamMeta =
194
- | { kind: "frame"; index: number }
223
+ | { kind: "notice" }
224
+ | {
225
+ kind: "frame";
226
+ index: number;
227
+ reply: "re" | "empty";
228
+ afterStop?: true;
229
+ }
195
230
  | {
196
231
  kind: "summary";
197
232
  stopReason: ApiStreamStopReason;
233
+ /** Every reply line: `rows + empty`. */
198
234
  frames: number;
235
+ /** `!re` rows. `--count` counts these. */
236
+ rows: number;
237
+ /** `!empty` replies. */
238
+ empty: number;
199
239
  durationMs: number;
200
240
  };
201
241
 
@@ -203,7 +243,11 @@ export type ApiStreamMeta =
203
243
  export interface ApiStreamSummary {
204
244
  stopReason: ApiStreamStopReason;
205
245
  frames: number;
246
+ rows: number;
247
+ empty: number;
206
248
  durationMs: number;
249
+ /** Attributes from a received terminal `!done` (for example `ret`). */
250
+ done?: Record<string, string>;
207
251
  }
208
252
 
209
253
  export interface ApiOperationMeta {
@@ -233,14 +277,25 @@ export interface ResolvedApiRequest {
233
277
  verb: ApiVerb;
234
278
  /** A `/execute` script-run (CLI string, not a path) — inspect gate is not-applicable. */
235
279
  scriptMode: boolean;
280
+ /** The menu's `listen` command was requested (a `/listen` endpoint). */
236
281
  listen: boolean;
282
+ /** Deliver replies incrementally (`--stream`, or implied by `listen`). */
283
+ stream: boolean;
237
284
  /** Resolved JSON body (from `-f` / `-d` / `--input`). */
238
285
  body: Record<string, string>;
239
- /** REST `.query` words (no `?` prefix), structured then raw, in order. */
286
+ /**
287
+ * REST `.query` words (no `?` prefix), structured then raw, in order. A bare
288
+ * `--query` name reads "is set" here until validation asks the device which
289
+ * bare names are booleans ({@link withQueryBooleans}).
290
+ */
240
291
  query: readonly string[];
292
+ /** The parsed `--query` expressions, when any were given. */
293
+ structuredQuery?: ParsedQuery;
294
+ /** `--raw-query` words, verbatim. */
295
+ rawQuery: readonly string[];
241
296
  proplist: readonly string[];
242
297
  raw: boolean;
243
- /** `--count`: stop a `--stream` after N frames. */
298
+ /** `--count`: stop a `--stream` after N `!re` rows (`!empty` does not count). */
244
299
  count?: number;
245
300
  /** `--duration` parsed to ms: stop a `--stream` after this wall-clock window. */
246
301
  durationMs?: number;
@@ -295,10 +350,11 @@ export async function apiEnvelope(
295
350
  }
296
351
 
297
352
  export async function runResolvedApi(
298
- resolved: ResolvedApiRequest,
353
+ request: ResolvedApiRequest,
299
354
  trace: ValidationTrace = {},
300
355
  ): Promise<ApiSuccessEnvelope> {
301
- assertListenCapability(resolved);
356
+ let resolved = request;
357
+ assertStreamCapability(resolved);
302
358
  // Validate the complete request shape before constructing a backend or
303
359
  // running either validation stage. In particular, `/execute` must reject
304
360
  // extra body fields deterministically instead of letting live `:parse`
@@ -320,18 +376,24 @@ export async function runResolvedApi(
320
376
  let validation: EnvelopeValidationMeta;
321
377
  let tips: Tip[] = [];
322
378
  try {
379
+ // Recorded the moment the gate settles, so a failure in the boolean probe
380
+ // or the request below reports the gate that passed rather than a guess.
323
381
  if (resolved.validate.value) {
324
382
  const result = await validateApiRequest(resolved, backend, trace);
325
383
  validation = result.validation;
384
+ trace.validation = validation;
326
385
  tips = result.tips;
386
+ resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
327
387
  } else {
328
388
  validation = disabledApiValidationMeta(resolved);
389
+ trace.validation = validation;
329
390
  }
330
- // Recorded BEFORE the request, so a transport drop or RouterOS fault below
331
- // reports the gate that actually settled rather than a guess.
332
- trace.validation = validation;
333
391
 
334
- const result = await backend.apiRequest(protocolRequest);
392
+ const result = await backend.apiRequest(
393
+ resolved === request
394
+ ? protocolRequest
395
+ : buildProtocolApiRequest(resolved),
396
+ );
335
397
  const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
336
398
  if (routerOsFailure) {
337
399
  throw routerOsFailure;
@@ -378,27 +440,29 @@ function apiRouterOsFailureFromResult(
378
440
  }
379
441
 
380
442
  /**
381
- * The open-ended `--stream` follow path: an async generator yielding one
382
- * {@link ApiEnvelope} per `/listen` change frame, then a terminating summary
383
- * envelope (`data` = {@link ApiStreamSummary}). Open-ended follow is native-api
384
- * only — a `rest-api` (or otherwise unresolved) transport yields a single error
385
- * envelope. `--count`/`--duration` and `externalSignal` (Ctrl-C) bound it.
386
- * Errors before the first frame (resolution, validation, confirmation,
387
- * capability) yield one error envelope and stop — the CLI keys its exit code on
388
- * that first envelope. `onListening` fires once the follow is established on the
389
- * wire (a real barrier for callers that must act only after that point).
443
+ * The `--stream` path: an async generator yielding one {@link ApiEnvelope} per
444
+ * native reply (`!re` row or `!empty`) to the literal command, then a
445
+ * terminating summary envelope (`data` = {@link ApiStreamSummary}). It never
446
+ * rewrites the command; a `<menu>/listen` endpoint is how to follow changes.
447
+ * Streaming is native-api only — a `rest-api` (or otherwise unresolved)
448
+ * transport yields a single error envelope. `--count`/`--duration` and
449
+ * `externalSignal` (Ctrl-C) bound it. Errors before the first frame
450
+ * (resolution, validation, confirmation, capability) yield one error envelope
451
+ * and stop — the CLI keys its exit code on that first envelope. `onListening`
452
+ * fires once the command is on the wire (a real barrier for callers that must
453
+ * act only after that point).
390
454
  */
391
- export async function* apiListen(
455
+ export async function* apiStream(
392
456
  request: ApiRequest,
393
457
  env: Record<string, string | undefined> = Bun.env,
394
458
  externalSignal?: AbortSignal,
395
459
  onListening?: () => void,
396
460
  ): AsyncGenerator<ApiEnvelope, void, void> {
397
- // `apiListen` is inherently the streaming surface, so force `listen` on the
398
- // request: otherwise a caller that omits it (and the `/listen` endpoint form)
399
- // would resolve `via` to the rest-api default and be rejected. An explicit
400
- // `--via rest-api` still surfaces capability-unsupported below.
401
- const streamRequest: ApiRequest = { ...request, listen: true };
461
+ // `apiStream` is inherently the streaming surface, so force `stream` on the
462
+ // request: otherwise a caller that omits it would resolve `via` to the
463
+ // rest-api default and be rejected. An explicit `--via rest-api` still
464
+ // surfaces capability-unsupported below.
465
+ const streamRequest: ApiRequest = { ...request, stream: true };
402
466
  let resolved: ResolvedApiRequest | undefined;
403
467
  try {
404
468
  resolved = await resolveApiRequest(streamRequest, env);
@@ -406,15 +470,16 @@ export async function* apiListen(
406
470
  if (resolved.via.value !== "native-api") {
407
471
  throw new CentrsError({
408
472
  code: "transport/capability-unsupported",
409
- summary: "REST cannot follow an open-ended `--stream` (60s cap).",
473
+ summary: "Incremental `--stream` replies require native-api.",
410
474
  remediation:
411
- "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
412
- context: { via: resolved.via.value, capability: "listen" },
475
+ "Use `--via native-api`, or drop `--stream` for a bounded one-shot response.",
476
+ context: { via: resolved.via.value, capability: "stream" },
413
477
  });
414
478
  }
415
479
  } catch (error) {
480
+ // Every failure here comes before stage 1, so an empty trace says so.
416
481
  yield resolved
417
- ? buildApiErrorEnvelopeFromResolved(resolved, error)
482
+ ? buildApiErrorEnvelopeFromResolved(resolved, error, {})
418
483
  : buildApiErrorEnvelope(streamRequest, error, env);
419
484
  return;
420
485
  }
@@ -422,32 +487,55 @@ export async function* apiListen(
422
487
  }
423
488
 
424
489
  async function* streamResolvedApi(
425
- resolved: ResolvedApiRequest,
490
+ request: ResolvedApiRequest,
426
491
  externalSignal?: AbortSignal,
427
492
  onListening?: () => void,
428
493
  ): AsyncGenerator<ApiEnvelope, void, void> {
429
- const backend = adapterForResolved(resolved);
494
+ const backend = adapterForResolved(request);
430
495
 
496
+ let resolved = request;
431
497
  let validation: EnvelopeValidationMeta;
498
+ const trace: ValidationTrace = {};
432
499
  try {
433
- validation = resolved.validate.value
434
- ? (await validateApiRequest(resolved, backend)).validation
435
- : disabledValidationMeta();
500
+ if (resolved.validate.value) {
501
+ const result = await validateApiRequest(resolved, backend, trace);
502
+ validation = result.validation;
503
+ // The boolean probe below runs after the gate passed (GH#435).
504
+ trace.validation = validation;
505
+ resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
506
+ } else {
507
+ validation = disabledValidationMeta();
508
+ }
436
509
  } catch (error) {
437
510
  await backend.close();
438
- yield buildApiErrorEnvelopeFromResolved(resolved, error);
511
+ yield buildApiErrorEnvelopeFromResolved(resolved, error, trace);
439
512
  return;
440
513
  }
441
514
 
515
+ // Request-shape advice is known before the first reply: deliver it first,
516
+ // and keep it on whichever terminal summary follows (#409 review).
517
+ const tips = streamTips(resolved);
442
518
  const controller = new AbortController();
443
519
  const startedAt = Date.now();
444
- let frames = 0;
520
+ const counts = { frames: 0, rows: 0, empty: 0 };
445
521
  let stopReason: ApiStreamStopReason | undefined;
446
522
  let cancelUnacknowledged = false;
523
+ let done: Record<string, string> | undefined;
524
+ const cancellationWarnings = (): Warning[] =>
525
+ cancelUnacknowledged
526
+ ? [
527
+ {
528
+ code: "transport/cancel-unacknowledged",
529
+ message: `RouterOS did not acknowledge /cancel within ${resolved.timeoutMs.value}ms; centrs closed the session locally.`,
530
+ context: { timeoutMs: resolved.timeoutMs.value },
531
+ },
532
+ ]
533
+ : [];
447
534
  let durationTimer: ReturnType<typeof setTimeout> | undefined;
448
535
  // The first stop cause wins: the cancel grace window keeps the loop alive
449
536
  // after a stop, so a later timer or frame must not relabel it.
450
537
  const onExternalAbort = (): void => {
538
+ if (done !== undefined) return;
451
539
  stopReason ??= "interrupted";
452
540
  clearTimeout(durationTimer);
453
541
  controller.abort();
@@ -467,51 +555,69 @@ async function* streamResolvedApi(
467
555
  }
468
556
 
469
557
  try {
558
+ // A consumer may return immediately after this notice; keep it inside
559
+ // the session cleanup scope, just like every wire reply.
560
+ if (tips.length > 0) {
561
+ yield streamNoticeEnvelope(resolved, validation, tips);
562
+ }
470
563
  const protocolRequest = buildProtocolApiRequest(resolved);
471
- for await (const record of backend.listen(protocolRequest, {
564
+ for await (const reply of backend.stream(protocolRequest, {
472
565
  signal: controller.signal,
473
566
  onListening,
474
567
  onCancelUnacknowledged: () => {
475
568
  cancelUnacknowledged = true;
476
569
  },
570
+ onDone: (attributes) => {
571
+ done = attributes;
572
+ clearTimeout(durationTimer);
573
+ },
477
574
  })) {
478
- frames += 1;
479
- yield streamFrameEnvelope(resolved, validation, record, frames);
480
- if (resolved.count !== undefined && frames >= resolved.count) {
575
+ counts.frames += 1;
576
+ if (reply.type === "empty") counts.empty += 1;
577
+ else counts.rows += 1;
578
+ yield streamFrameEnvelope(resolved, validation, reply, counts.frames);
579
+ // `--count` counts rows only: an empty tick must never use up the
580
+ // count before the data it is waiting for arrives.
581
+ if (
582
+ reply.type === "re" &&
583
+ resolved.count !== undefined &&
584
+ counts.rows >= resolved.count
585
+ ) {
481
586
  stopReason ??= "count-reached";
482
587
  break;
483
588
  }
484
589
  }
590
+ const routerOsFailure = apiRouterOsFailureFromResult(resolved, {
591
+ data: done?.["ret"],
592
+ });
593
+ if (routerOsFailure) throw routerOsFailure;
485
594
  yield streamSummaryEnvelope(
486
595
  resolved,
487
596
  validation,
488
- stopReason ?? "interrupted",
489
- frames,
597
+ stopReason ?? "completed",
598
+ counts,
490
599
  Date.now() - startedAt,
491
- cancelUnacknowledged
492
- ? [
493
- {
494
- code: "transport/cancel-unacknowledged",
495
- message: `RouterOS did not acknowledge /cancel within ${resolved.timeoutMs.value}ms; centrs closed the session locally.`,
496
- context: { timeoutMs: resolved.timeoutMs.value },
497
- },
498
- ]
499
- : [],
600
+ tips,
601
+ cancellationWarnings(),
602
+ done,
500
603
  );
501
604
  } catch (error) {
502
- // A mid-stream failure is itself a frame, tagged `stream.kind="frame"` so
503
- // consumers that key off `meta.operation.stream` still see it in the stream
504
- // (not as a stray non-stream envelope); the summary then closes with
505
- // `transport-error` (the CLI's exit code keys on whether it *started*
506
- // cleanly).
507
- yield streamErrorFrameEnvelope(resolved, error, frames + 1);
508
- yield streamSummaryEnvelope(
509
- resolved,
510
- validation,
511
- "transport-error",
512
- frames,
513
- Date.now() - startedAt,
514
- );
605
+ const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
606
+ envelope.meta.validation = validation;
607
+ envelope.warnings = [...envelope.warnings, ...cancellationWarnings()];
608
+ envelope.tips = [...envelope.tips, ...tips];
609
+ if (envelope.meta.operation) {
610
+ envelope.meta.operation.stream = {
611
+ kind: "summary",
612
+ stopReason: envelope.error.code.startsWith("routeros/")
613
+ ? "routeros-error"
614
+ : "transport-error",
615
+ ...counts,
616
+ durationMs: Date.now() - startedAt,
617
+ };
618
+ envelope.meta.operation.objectCount = counts.rows;
619
+ }
620
+ yield envelope;
515
621
  } finally {
516
622
  if (durationTimer !== undefined) {
517
623
  clearTimeout(durationTimer);
@@ -524,53 +630,114 @@ async function* streamResolvedApi(
524
630
  function streamFrameEnvelope(
525
631
  resolved: ResolvedApiRequest,
526
632
  validation: EnvelopeValidationMeta,
527
- record: Record<string, unknown>,
633
+ reply: ProtocolStreamReply,
528
634
  index: number,
529
635
  ): ApiSuccessEnvelope {
530
- const meta = metaFromResolved(resolved, validation, record);
531
- meta.operation.stream = { kind: "frame", index };
532
- return { ok: true, data: record, warnings: [], tips: [], meta };
636
+ const data = reply.type === "empty" ? null : reply.attributes;
637
+ const meta = metaFromResolved(resolved, validation, data);
638
+ meta.operation.stream = {
639
+ kind: "frame",
640
+ index,
641
+ reply: reply.type,
642
+ ...(reply.afterStop ? { afterStop: true as const } : {}),
643
+ };
644
+ return { ok: true, data, warnings: [], tips: [], meta };
533
645
  }
534
646
 
535
- /**
536
- * A mid-stream failure rendered as a stream frame: the error envelope carries
537
- * `meta.operation.stream.kind="frame"` (index after the last good frame) so
538
- * stream consumers can place it on the timeline rather than mistaking it for a
539
- * non-stream envelope.
540
- */
541
- function streamErrorFrameEnvelope(
647
+ function streamNoticeEnvelope(
542
648
  resolved: ResolvedApiRequest,
543
- error: unknown,
544
- index: number,
545
- ): ApiErrorEnvelope {
546
- const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
547
- if (envelope.meta.operation) {
548
- envelope.meta.operation.stream = { kind: "frame", index };
549
- }
550
- return envelope;
649
+ validation: EnvelopeValidationMeta,
650
+ tips: Tip[],
651
+ ): ApiSuccessEnvelope {
652
+ const meta = metaFromResolved(resolved, validation, null);
653
+ meta.operation.stream = { kind: "notice" };
654
+ return { ok: true, data: null, warnings: [], tips, meta };
551
655
  }
552
656
 
553
657
  function streamSummaryEnvelope(
554
658
  resolved: ResolvedApiRequest,
555
659
  validation: EnvelopeValidationMeta,
556
660
  stopReason: ApiStreamStopReason,
557
- frames: number,
661
+ counts: { frames: number; rows: number; empty: number },
558
662
  durationMs: number,
663
+ tips: Tip[],
559
664
  streamWarnings: readonly Warning[] = [],
665
+ done?: Record<string, string>,
560
666
  ): ApiSuccessEnvelope {
561
- const summary: ApiStreamSummary = { stopReason, frames, durationMs };
667
+ const summary: ApiStreamSummary = {
668
+ stopReason,
669
+ ...counts,
670
+ durationMs,
671
+ ...(done && Object.keys(done).length > 0 ? { done } : {}),
672
+ };
562
673
  const meta = metaFromResolved(resolved, validation, summary);
563
- meta.operation.objectCount = frames;
564
- meta.operation.stream = { kind: "summary", stopReason, frames, durationMs };
674
+ meta.operation.objectCount = counts.rows;
675
+ meta.operation.stream = {
676
+ kind: "summary",
677
+ stopReason,
678
+ ...counts,
679
+ durationMs,
680
+ };
565
681
  return {
566
682
  ok: true,
567
683
  data: summary,
568
684
  warnings: [...resolved.warnings, ...streamWarnings],
569
- tips: [],
685
+ tips,
570
686
  meta,
571
687
  };
572
688
  }
573
689
 
690
+ /**
691
+ * `api` sends the command as typed, so what RouterOS will not report is said
692
+ * as a tip, not fixed by rewriting the request (#396 decisions). Grounded on
693
+ * CHR 7.23.7 + 7.24.5: a GET stream is one finite print; a filtered change feed
694
+ * drops rows leaving the filter and every delete except under an `?.id=`
695
+ * query; a `.proplist` without `.id,.dead` strips both from a delete frame,
696
+ * even under `?.id=` (it arrives as an `!re` with no attributes).
697
+ */
698
+ function streamTips(resolved: ResolvedApiRequest): Tip[] {
699
+ const tips: Tip[] = [];
700
+ const menu = resolved.id ? `${resolved.path}/${resolved.id}` : resolved.path;
701
+ if (resolved.verb === "print" && !resolved.listen) {
702
+ tips.push(
703
+ buildTip(
704
+ "tip/stream-print",
705
+ `GET ${menu} --stream streamed one print; it does not follow changes.`,
706
+ `To follow changes, request the menu's listen: \`centrs api <router> ${menu}/listen\` (or POST \`${resolved.path}/print -f follow-only=${resolved.id ? ` --query .id=${resolved.id}` : ""}\`).`,
707
+ ),
708
+ );
709
+ }
710
+ const follows =
711
+ resolved.listen ||
712
+ "follow-only" in resolved.body ||
713
+ "follow" in resolved.body;
714
+ if (!follows) return tips;
715
+ const filtered = resolved.query.some((word) => !word.startsWith(".id="));
716
+ if (filtered) {
717
+ tips.push(
718
+ buildTip(
719
+ "tip/filtered-follow",
720
+ "RouterOS ran this change feed with a query, so it reports neither rows that leave the filter nor deletes.",
721
+ "Follow the unfiltered menu and filter on the client, or address one row (`<menu>/*id/listen`), whose delete is reported.",
722
+ ),
723
+ );
724
+ }
725
+ const projection = resolved.proplist;
726
+ if (
727
+ projection.length > 0 &&
728
+ !(projection.includes(".id") && projection.includes(".dead"))
729
+ ) {
730
+ tips.push(
731
+ buildTip(
732
+ "tip/follow-proplist",
733
+ "This --proplist omits `.id` or `.dead`, which RouterOS then strips from delete frames: a delete can arrive as a row with no attributes.",
734
+ "Add `.id,.dead` to --proplist to see deletes.",
735
+ ),
736
+ );
737
+ }
738
+ return tips;
739
+ }
740
+
574
741
  function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
575
742
  return createProtocolAdapter({
576
743
  protocol: resolved.via.value,
@@ -616,7 +783,9 @@ export async function resolveApiRequest(
616
783
  const normalized = normalizeApiEndpoint(request.endpoint);
617
784
  const method = parseApiMethod(request.method);
618
785
  const verb = mapMethodToVerb(method);
619
- const listen = (request.listen ?? false) || normalized.listen;
786
+ const listen = normalized.listen;
787
+ // A listen never completes, so it is only ever streamed.
788
+ const stream = (request.stream ?? false) || listen;
620
789
  // `/execute` (root, single token) run via POST is the script surface — a CLI
621
790
  // string, not a menu path. A GET, a nested path, or any other menu that merely
622
791
  // ends in `execute` stays a normal path request validated through inspect.
@@ -635,10 +804,59 @@ export async function resolveApiRequest(
635
804
  context: { method, path: normalized.path },
636
805
  });
637
806
  }
807
+ // PUT/POST map no row id onto the wire: native would drop it silently and
808
+ // REST would fold it into `.query`, so the transports would disagree.
809
+ if ((verb === "add" || verb === "run") && normalized.id !== undefined) {
810
+ throw new CentrsError({
811
+ code: "input/invalid-path",
812
+ summary: `A ${method} request does not address a row by id (${normalized.id}).`,
813
+ remediation:
814
+ "Drop the trailing id; use PATCH/DELETE for one row, or pass `.id` as a command field such as `-f .id=*1`.",
815
+ context: { method, path: normalized.path, id: normalized.id },
816
+ });
817
+ }
638
818
  const raw = request.raw ?? false;
639
819
  const body = buildApiBody(request);
640
- const query = buildApiQuery(request);
820
+ const structuredQuery =
821
+ (request.query?.length ?? 0) > 0
822
+ ? parseQueries(request.query ?? [])
823
+ : undefined;
824
+ const rawQuery = [...(request.rawQuery ?? [])];
825
+ const query = [
826
+ ...(structuredQuery ? compileQueryWords(structuredQuery, new Set()) : []),
827
+ ...rawQuery,
828
+ ];
641
829
  const proplist = buildApiProplist(request);
830
+ if (
831
+ ((verb !== "print" && verb !== "run") || scriptMode) &&
832
+ (query.length > 0 || proplist.length > 0)
833
+ ) {
834
+ throw new CentrsError({
835
+ code: "usage/conflicting-flags",
836
+ summary:
837
+ "Query and projection flags require a GET or a structured POST command.",
838
+ remediation:
839
+ "Remove --query/--raw-query/--proplist from mutation and script requests; address PATCH/DELETE by row id.",
840
+ });
841
+ }
842
+ if (listen && method !== "GET") {
843
+ throw new CentrsError({
844
+ code: "usage/conflicting-flags",
845
+ summary: "An explicit /listen endpoint requires GET.",
846
+ remediation:
847
+ "Drop -X to follow that menu, or POST the menu's print with `-f follow-only=` to pass print arguments.",
848
+ });
849
+ }
850
+ // A GET maps to print, which takes no body here; refuse the fields rather
851
+ // than drop them from a stream.
852
+ if (stream && verb === "print" && Object.keys(body).length > 0) {
853
+ throw new CentrsError({
854
+ code: "usage/conflicting-flags",
855
+ summary: "A streamed GET does not accept command fields.",
856
+ remediation:
857
+ "Use -X POST with the full command path to stream command attributes, such as /system/resource/print -f interval=1.",
858
+ });
859
+ }
642
860
 
643
861
  // A quickchr target is a named-live-provider: host/port/auth come from the
644
862
  // live descriptor, bypassing CDB, `__default__`, and MAC resolution.
@@ -661,7 +879,7 @@ export async function resolveApiRequest(
661
879
  env,
662
880
  config,
663
881
  )));
664
- const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
882
+ const via = resolveApiProtocol(request, env, stream, cdbResolution, config);
665
883
  const format = resolveApiFormat(request, env, config);
666
884
  // `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
667
885
  // explicit `--validate` and above every ambient source, so:
@@ -697,6 +915,19 @@ export async function resolveApiRequest(
697
915
  cdbResolution?.overrides.validate,
698
916
  config,
699
917
  );
918
+ if (!validate.value && (structuredQuery?.bareNames.length ?? 0) > 0) {
919
+ throw new CentrsError({
920
+ code: "input/invalid-query",
921
+ summary: `A bare name in --query (${structuredQuery?.bareNames.join(", ")}) needs validation to learn whether it is a boolean.`,
922
+ remediation:
923
+ 'Drop `--validate=false`/`--raw`, or spell the word out: `--query disabled=yes` for a boolean, `--raw-query comment` for "is set".',
924
+ context: {
925
+ query: request.query,
926
+ bareNames: structuredQuery?.bareNames,
927
+ validate: validate.source,
928
+ },
929
+ });
930
+ }
700
931
  const timeoutMs = resolveApiTimeout(
701
932
  request.timeout,
702
933
  env,
@@ -777,8 +1008,11 @@ export async function resolveApiRequest(
777
1008
  verb,
778
1009
  scriptMode,
779
1010
  listen,
1011
+ stream,
780
1012
  body,
781
1013
  query,
1014
+ structuredQuery,
1015
+ rawQuery,
782
1016
  proplist,
783
1017
  raw,
784
1018
  count: request.count,
@@ -917,38 +1151,36 @@ function parseJsonBody(raw: string, flag: string): Record<string, string> {
917
1151
  }
918
1152
 
919
1153
  /**
920
- * Build REST `.query` words (no `?` prefix). Structured `--query` words first, in
921
- * order, then verbatim `--raw-query` words. Mapping is CHR-grounded
922
- * (`commands/api/AGENTS.md`): `name=value` (eq), `name!=value` → `name=value`,
923
- * `#!` (eq then NOT-top), `name>value`/`name<value` → `>name=value`/`<name=value`,
924
- * bare `name` (has-property).
1154
+ * REST `.query` words (no `?` prefix) for a request summary: structured
1155
+ * `--query` words first (`src/core/query.ts`, bare names as "is set"), then
1156
+ * verbatim `--raw-query` words. Throws `input/invalid-query` on a bad
1157
+ * expression.
925
1158
  */
926
1159
  export function buildApiQuery(request: ApiRequest): string[] {
927
- const words: string[] = [];
928
- for (const expression of request.query ?? []) {
929
- words.push(...queryExpressionToWords(expression));
930
- }
931
- for (const raw of request.rawQuery ?? []) {
932
- words.push(raw);
933
- }
934
- return words;
1160
+ const structured =
1161
+ (request.query?.length ?? 0) > 0
1162
+ ? compileQueryWords(parseQueries(request.query ?? []), new Set())
1163
+ : [];
1164
+ return [...structured, ...(request.rawQuery ?? [])];
935
1165
  }
936
1166
 
937
- function queryExpressionToWords(expression: string): string[] {
938
- const ne = expression.indexOf("!=");
939
- if (ne >= 0) {
940
- return [`${expression.slice(0, ne)}=${expression.slice(ne + 2)}`, "#!"];
941
- }
942
- const gt = expression.indexOf(">");
943
- if (gt >= 0) {
944
- return [`>${expression.slice(0, gt)}=${expression.slice(gt + 1)}`];
945
- }
946
- const lt = expression.indexOf("<");
947
- if (lt >= 0) {
948
- return [`<${expression.slice(0, lt)}=${expression.slice(lt + 1)}`];
949
- }
950
- // `name=value` (eq) or bare `name` (has-property) pass through verbatim.
951
- return [expression];
1167
+ /**
1168
+ * The resolved request with its `--query` bare names compiled by type: the
1169
+ * device says which are booleans (`disabled` → `disabled=yes`); the rest mean
1170
+ * "is set". Unchanged when there are no bare names.
1171
+ */
1172
+ export async function withQueryBooleans(
1173
+ resolved: ResolvedApiRequest,
1174
+ backend: ProtocolAdapter,
1175
+ menu: string,
1176
+ ): Promise<ResolvedApiRequest> {
1177
+ const parsed = resolved.structuredQuery;
1178
+ if (!parsed || parsed.bareNames.length === 0) return resolved;
1179
+ const booleans = await inspectWhereBooleans(backend, menu, parsed.bareNames);
1180
+ return {
1181
+ ...resolved,
1182
+ query: [...compileQueryWords(parsed, booleans), ...resolved.rawQuery],
1183
+ };
952
1184
  }
953
1185
 
954
1186
  function buildApiProplist(request: ApiRequest): string[] {
@@ -974,6 +1206,9 @@ export function buildProtocolApiRequest(
974
1206
  if (resolved.id) {
975
1207
  request.id = resolved.id;
976
1208
  }
1209
+ if (resolved.listen) {
1210
+ request.listen = true;
1211
+ }
977
1212
  if (resolved.scriptMode) {
978
1213
  const { script, ...extra } = resolved.body;
979
1214
  if (typeof script !== "string" || script.trim().length === 0) {
@@ -1005,7 +1240,7 @@ export function buildProtocolApiRequest(
1005
1240
  request.attributes = resolved.body;
1006
1241
  }
1007
1242
  }
1008
- if (resolved.verb === "print") {
1243
+ if (resolved.verb === "print" || resolved.verb === "run") {
1009
1244
  if (resolved.query.length > 0) {
1010
1245
  request.query = resolved.query;
1011
1246
  }
@@ -1023,6 +1258,8 @@ const API_SCRIPT_DEVICE_STAGE_SOURCE = ":put [:parse]";
1023
1258
  interface ApiValidationResult {
1024
1259
  validation: EnvelopeValidationMeta;
1025
1260
  tips: Tip[];
1261
+ /** The menu a `--query` bare name is probed in: the path's parent only when inspect confirmed its last segment is a command. */
1262
+ queryMenu: string;
1026
1263
  }
1027
1264
 
1028
1265
  /**
@@ -1086,6 +1323,7 @@ async function validateApiRequest(
1086
1323
  ],
1087
1324
  },
1088
1325
  tips: [],
1326
+ queryMenu: resolved.path,
1089
1327
  };
1090
1328
  }
1091
1329
 
@@ -1158,6 +1396,7 @@ async function validateApiRequest(
1158
1396
  semantic: true,
1159
1397
  },
1160
1398
  tips,
1399
+ queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
1161
1400
  };
1162
1401
  }
1163
1402
 
@@ -1202,6 +1441,7 @@ async function validateApiRequest(
1202
1441
  availableAttributes: available,
1203
1442
  },
1204
1443
  tips,
1444
+ queryMenu: resolved.path,
1205
1445
  };
1206
1446
  }
1207
1447
 
@@ -1214,6 +1454,7 @@ async function validateApiRequest(
1214
1454
  semantic: true,
1215
1455
  },
1216
1456
  tips,
1457
+ queryMenu: resolved.path,
1217
1458
  };
1218
1459
  }
1219
1460
 
@@ -1275,38 +1516,39 @@ async function assertApiWriteConfirmed(
1275
1516
  });
1276
1517
  }
1277
1518
 
1278
- function assertListenCapability(resolved: ResolvedApiRequest): void {
1279
- if (!resolved.listen) {
1519
+ function assertStreamCapability(resolved: ResolvedApiRequest): void {
1520
+ if (!resolved.stream) {
1280
1521
  return;
1281
1522
  }
1282
1523
  if (resolved.via.value === "rest-api") {
1283
1524
  throw new CentrsError({
1284
1525
  code: "transport/capability-unsupported",
1285
- summary: "REST cannot follow an open-ended `--stream` (60s cap).",
1526
+ summary: "Incremental `--stream` replies require native-api.",
1286
1527
  remediation:
1287
- "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
1288
- context: { via: resolved.via.value, capability: "listen" },
1528
+ "Use `--via native-api`, or drop `--stream` for a bounded one-shot response.",
1529
+ context: { via: resolved.via.value, capability: "stream" },
1289
1530
  });
1290
1531
  }
1291
1532
  throw new CentrsError({
1292
1533
  code: "input/invalid-command",
1293
- summary: "`--stream` is an open-ended follow, not a one-shot `api` call.",
1534
+ summary:
1535
+ "`--stream` yields incremental replies instead of a one-shot `api` result.",
1294
1536
  remediation:
1295
- "Consume the stream via `apiListen()` (library) or `centrs api … --stream` (CLI); both yield an NDJSON envelope per change frame plus a final summary.",
1296
- context: { via: resolved.via.value, capability: "listen" },
1537
+ "Consume the stream via `apiStream()` (library) or `centrs api … --stream` (CLI); both yield an NDJSON envelope per reply frame plus a final summary.",
1538
+ context: { via: resolved.via.value, capability: "stream" },
1297
1539
  });
1298
1540
  }
1299
1541
 
1300
1542
  function resolveApiProtocol(
1301
1543
  request: ApiRequest,
1302
1544
  env: Record<string, string | undefined>,
1303
- listen: boolean,
1545
+ stream: boolean,
1304
1546
  cdb?: CdbResolution,
1305
1547
  config: Record<string, string | undefined> = {},
1306
1548
  ): ResolvedSetting<RouterOsProtocol> {
1307
- // A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
1308
- // REST stays the default for one-shot calls.
1309
- const defaultVia: RouterOsProtocol = listen ? "native-api" : "rest-api";
1549
+ // A stream (`--stream` or a `/listen` endpoint) infers native-api when
1550
+ // `--via` is unset; REST stays the default for one-shot calls.
1551
+ const defaultVia: RouterOsProtocol = stream ? "native-api" : "rest-api";
1310
1552
  const via = resolveStringSetting(
1311
1553
  request.via,
1312
1554
  env,
@@ -1450,7 +1692,9 @@ export function buildApiErrorEnvelope(
1450
1692
  write: parsedMethod
1451
1693
  ? isApiMutating(parsedMethod, reportedPath)
1452
1694
  : false,
1453
- listen: request.listen ?? false,
1695
+ listen: safeNormalizeListen(request.endpoint),
1696
+ stream:
1697
+ (request.stream ?? false) || safeNormalizeListen(request.endpoint),
1454
1698
  yes: request.yes ?? false,
1455
1699
  validate: summarizedValidate(request),
1456
1700
  raw: request.raw ?? false,
@@ -1515,25 +1759,50 @@ function failedApiValidationMeta(
1515
1759
  ],
1516
1760
  };
1517
1761
  }
1518
- // `stages` is only asserted when a `validation/*` error identifies the device
1519
- // stage as the rejecter. Everything downstream — a RouterOS fault on the
1520
- // request, a transport drop — passed the gate and is served by
1521
- // `trace.validation` before this function is ever reached.
1522
- const deviceRejected = error.code.startsWith("validation/");
1762
+ // Only a `validation/*` error identifies the device stage as the rejecter.
1763
+ // Everything downstream of a passed gate — a RouterOS fault on the request,
1764
+ // a transport drop — is served by `trace.validation` before this function is
1765
+ // ever reached.
1766
+ if (error.code.startsWith("validation/")) {
1767
+ return {
1768
+ enabled: true,
1769
+ source: apiDeviceStageSource(resolved),
1770
+ result: "failed",
1771
+ syntax: false,
1772
+ semantic: resolved.scriptMode ? "not-applicable" : false,
1773
+ stages: [offlineApiStage(resolved, trace), deviceStageFailed(resolved)],
1774
+ };
1775
+ }
1776
+ // Anything else stopped the gate before it produced a verdict: a transport or
1777
+ // auth failure, a device error that is not a gate answer, or a refusal before
1778
+ // the gate ran (write confirmation, a non-native `--stream`). No `result`, no
1779
+ // `syntax` / `semantic` — no stage said either (GH#435). Without a trace a
1780
+ // script request cannot say whether stage 1 ran, so that stage is left out.
1781
+ const reason = notReachedReason(error.code);
1782
+ const source = apiDeviceStageSource(resolved);
1783
+ const device: EnvelopeValidationStage = {
1784
+ stage: "device",
1785
+ source,
1786
+ result: "skipped",
1787
+ reason,
1788
+ };
1789
+ if (resolved.scriptMode && !trace) {
1790
+ return { enabled: true, source, stages: [device] };
1791
+ }
1523
1792
  return {
1524
1793
  enabled: true,
1525
- source: apiDeviceStageSource(resolved),
1526
- result: "failed",
1527
- syntax: false,
1528
- semantic: resolved.scriptMode ? "not-applicable" : false,
1529
- ...(deviceRejected
1530
- ? {
1531
- stages: [
1532
- offlineApiStage(resolved, trace),
1533
- deviceStageFailed(resolved),
1534
- ],
1535
- }
1536
- : {}),
1794
+ source,
1795
+ stages: [
1796
+ resolved.scriptMode && trace?.offlineVerdict === undefined
1797
+ ? {
1798
+ stage: "offline",
1799
+ source: OFFLINE_GATE_SOURCE,
1800
+ result: "skipped",
1801
+ reason,
1802
+ }
1803
+ : offlineApiStage(resolved, trace),
1804
+ device,
1805
+ ],
1537
1806
  };
1538
1807
  }
1539
1808
 
@@ -1635,12 +1904,16 @@ export function renderApiEnvelope(
1635
1904
  switch (format) {
1636
1905
  case "json":
1637
1906
  return JSON.stringify(envelope, null, 2);
1907
+ // One envelope, one compact line: the same line shape as a stream, so a
1908
+ // one-shot read and a `--stream` are both read line by line.
1909
+ case "ndjson":
1910
+ return JSON.stringify(envelope);
1638
1911
  case "yaml":
1639
1912
  return toYaml(envelope);
1640
1913
  case "text":
1641
1914
  return envelope.ok
1642
1915
  ? renderApiSuccessText(envelope, options)
1643
- : renderApiErrorText(envelope, options);
1916
+ : formatCentrsErrorText(envelope.error, options);
1644
1917
  default:
1645
1918
  return exhaustiveFormat(format);
1646
1919
  }
@@ -1670,20 +1943,27 @@ export function renderApiStreamLine(
1670
1943
  }
1671
1944
  // A streamed error must stay one line too — never the verbose blank-line +
1672
1945
  // pretty-printed context block that `renderApiErrorText` emits.
1673
- const error = envelope.error;
1674
- return error.remediation
1675
- ? `[${error.code}] ${error.summary} — Fix: ${error.remediation}`
1676
- : `[${error.code}] ${error.summary}`;
1946
+ return formatCentrsErrorLine(envelope.error);
1677
1947
  }
1678
- // json and yaml both stream as NDJSON: one compact envelope object per line.
1679
- // (yaml falls back to JSON — a multi-line YAML doc can't be one stream line.)
1948
+ // ndjson, json and yaml all stream as NDJSON: one compact envelope object
1949
+ // per line. (json keeps this for compatibility; yaml falls back to JSON — a
1950
+ // multi-line YAML doc can't be one stream line.)
1680
1951
  return JSON.stringify(envelope);
1681
1952
  }
1682
1953
 
1683
1954
  function renderApiStreamFrameText(envelope: ApiSuccessEnvelope): string {
1684
1955
  const stream = envelope.meta.operation?.stream;
1685
1956
  if (stream?.kind === "summary") {
1686
- return `— ${stream.stopReason}: ${stream.frames} frame(s) in ${stream.durationMs}ms`;
1957
+ const empty = stream.empty > 0 ? `, ${stream.empty} empty` : "";
1958
+ return `— ${stream.stopReason}: ${stream.rows} row(s)${empty} in ${stream.durationMs}ms`;
1959
+ }
1960
+ if (stream?.kind === "notice") {
1961
+ return envelope.tips
1962
+ .map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
1963
+ .join("\n");
1964
+ }
1965
+ if (stream?.kind === "frame" && stream.reply === "empty") {
1966
+ return `${stream.index}\t!empty`;
1687
1967
  }
1688
1968
  const index = stream?.kind === "frame" ? stream.index : 0;
1689
1969
  return `${index}\t${JSON.stringify(envelope.data)}`;
@@ -1725,21 +2005,12 @@ function renderApiSuccessText(
1725
2005
  return lines.join("\n");
1726
2006
  }
1727
2007
 
1728
- function renderApiErrorText(
1729
- envelope: ApiErrorEnvelope,
1730
- options: { verbose?: boolean },
1731
- ): string {
1732
- const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
1733
- if (envelope.error.remediation) {
1734
- lines.push(`Fix: ${envelope.error.remediation}`);
1735
- }
1736
- if (options.verbose && envelope.error.context) {
1737
- lines.push("", JSON.stringify(envelope.error.context, null, 2));
1738
- }
1739
- return lines.join("\n");
1740
- }
1741
-
1742
2008
  export function validateApiRequestShape(request: ApiRequest): void {
2009
+ // `listen` used to mean "stream" AND rewrote a GET into a change feed (#402).
2010
+ // Refuse it rather than let an untyped caller silently get a finite print.
2011
+ if ("listen" in request) {
2012
+ throw removedListenError(request.endpoint, "library");
2013
+ }
1743
2014
  if (!request.endpoint || request.endpoint.trim().length === 0) {
1744
2015
  throw new CentrsError({
1745
2016
  code: "input/invalid-command",
@@ -1759,6 +2030,31 @@ export function validateApiRequestShape(request: ApiRequest): void {
1759
2030
  }
1760
2031
  }
1761
2032
 
2033
+ /**
2034
+ * `--listen` / `ApiRequest.listen` were removed in #402: they meant "stream"
2035
+ * and also turned a GET into a change feed, which a literal `api` must not do.
2036
+ * The error names the exact replacement instead of guessing which was meant.
2037
+ */
2038
+ export function removedListenError(
2039
+ endpoint: string,
2040
+ surface: "cli" | "library",
2041
+ replacement?: string,
2042
+ ): CentrsError {
2043
+ const menu = safeNormalizeMenu(endpoint);
2044
+ return new CentrsError({
2045
+ code: "usage/removed-flag",
2046
+ summary:
2047
+ surface === "cli"
2048
+ ? "`--listen` was removed: `api` no longer rewrites a request into a listen."
2049
+ : "`ApiRequest.listen` was removed: `api` no longer rewrites a request into a listen.",
2050
+ remediation:
2051
+ surface === "cli"
2052
+ ? `Run: ${replacement ?? `centrs api <router> ${menu}/listen`}${replacement?.includes("<") ? " (fill each <placeholder> in from your command)" : ""}. A \`/listen\` endpoint follows changes; \`--stream\` streams any other command as typed.`
2053
+ : `To follow changes, use \`apiStream()\` with endpoint \`${menu}/listen\`; to stream another command as typed, set \`stream: true\`.`,
2054
+ context: { flag: surface === "cli" ? "--listen" : "listen", endpoint },
2055
+ });
2056
+ }
2057
+
1762
2058
  export function parseApiMethod(method: string | undefined): ApiMethod {
1763
2059
  if (method === undefined) {
1764
2060
  return "GET";
@@ -1786,7 +2082,7 @@ export function apiRequestSummaryFromRequest(
1786
2082
  ): ApiRequestSummary {
1787
2083
  const normalized = normalizeApiEndpoint(request.endpoint);
1788
2084
  const parsed = tryParseApiMethod(request.method);
1789
- const listen = (request.listen ?? false) || normalized.listen;
2085
+ const listen = normalized.listen;
1790
2086
  return {
1791
2087
  endpoint: request.endpoint,
1792
2088
  path: normalized.path,
@@ -1795,15 +2091,25 @@ export function apiRequestSummaryFromRequest(
1795
2091
  verb: parsed ? mapMethodToVerb(parsed) : null,
1796
2092
  write: parsed ? isApiMutating(parsed, normalized.path) : false,
1797
2093
  listen,
2094
+ stream: (request.stream ?? false) || listen,
1798
2095
  yes: request.yes ?? false,
1799
2096
  validate: summarizedValidate(request),
1800
2097
  raw: request.raw ?? false,
1801
2098
  format: resolveErrorFormat(request, env),
1802
- query: buildApiQuery(request),
2099
+ query: summaryQuery(request),
1803
2100
  proplist: buildApiProplist(request),
1804
2101
  };
1805
2102
  }
1806
2103
 
2104
+ /** {@link buildApiQuery} for an error summary, which must not throw on the expression it reports. */
2105
+ function summaryQuery(request: ApiRequest): string[] | undefined {
2106
+ try {
2107
+ return buildApiQuery(request);
2108
+ } catch {
2109
+ return undefined;
2110
+ }
2111
+ }
2112
+
1807
2113
  /** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
1808
2114
  function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1809
2115
  if (method === undefined) {
@@ -1815,6 +2121,24 @@ function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1815
2121
  : undefined;
1816
2122
  }
1817
2123
 
2124
+ /** The endpoint's path plus any row id (`/ip/address/*1`), without a `/listen`. */
2125
+ function safeNormalizeMenu(endpoint: string): string {
2126
+ try {
2127
+ const { path, id } = normalizeApiEndpoint(endpoint);
2128
+ return id ? `${path}/${id}` : path;
2129
+ } catch {
2130
+ return endpoint;
2131
+ }
2132
+ }
2133
+
2134
+ function safeNormalizeListen(endpoint: string): boolean {
2135
+ try {
2136
+ return normalizeApiEndpoint(endpoint).listen;
2137
+ } catch {
2138
+ return false;
2139
+ }
2140
+ }
2141
+
1818
2142
  function safeNormalizePath(endpoint: string): string {
1819
2143
  try {
1820
2144
  return normalizeApiEndpoint(endpoint).path;
@@ -1910,6 +2234,7 @@ function apiRequestSummary(resolved: ResolvedApiRequest): ApiRequestSummary {
1910
2234
  verb: resolved.verb,
1911
2235
  write: isApiMutating(resolved.method, resolved.path),
1912
2236
  listen: resolved.listen,
2237
+ stream: resolved.stream,
1913
2238
  yes: resolved.yes,
1914
2239
  validate: resolved.validate.value,
1915
2240
  raw: resolved.raw,