@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/api.ts CHANGED
@@ -3,15 +3,16 @@
3
3
  *
4
4
  * The structured middle of the verb trichotomy (see `commands/api/README.md`):
5
5
  * one command per operation (no code blocks), structured input **and** output,
6
- * validated through `/console/inspect`, runnable over REST or the native API. The
6
+ * validated through `/console/inspect` (or live `:parse` for `/execute` scripts),
7
+ * runnable over REST or the native API. The
7
8
  * orchestrator is transport-agnostic — it normalizes the endpoint, resolves the
8
- * method→verb map, runs the inspect gate and the write-confirmation gate, then
9
+ * method→verb map, runs the validation and write-confirmation gates, then
9
10
  * hands a normalized {@link ProtocolApiRequest} to the adapter. REST vs native
10
11
  * mechanics (id-in-URL vs `=.id=`, `.query` vs `?`-words) live in the adapter.
11
12
  *
12
13
  * Grounded RouterOS facts this depends on live in `commands/api/AGENTS.md`
13
- * (CHR 7.23.1). The `--listen` streaming path and multi-target fan-out are later
14
- * 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.
15
16
  */
16
17
 
17
18
  import type {
@@ -26,16 +27,31 @@ import type {
26
27
  ValidationTrace,
27
28
  Warning,
28
29
  } from "./core/envelope.ts";
29
- import { buildTip } from "./core/envelope.ts";
30
+ import { buildTip, notReachedReason } from "./core/envelope.ts";
30
31
  import {
31
32
  inspectArgumentNames,
32
33
  inspectChildrenOrEmpty,
34
+ inspectWhereBooleans,
33
35
  isCommandNode,
34
36
  pathTokens,
35
37
  } from "./core/inspect.ts";
38
+ import {
39
+ compileQueryWords,
40
+ type ParsedQuery,
41
+ parseQueries,
42
+ } from "./core/query.ts";
43
+ import { mapRouterOsResultError } from "./core/routeros-errors.ts";
36
44
  import { toYaml } from "./core/yaml.ts";
37
- import { CentrsError, serializeCentrsError } from "./errors.ts";
38
- import { promptForWriteConfirmation } from "./execute.ts";
45
+ import {
46
+ CentrsError,
47
+ formatCentrsErrorLine,
48
+ formatCentrsErrorText,
49
+ serializeCentrsError,
50
+ } from "./errors.ts";
51
+ import {
52
+ promptForWriteConfirmation,
53
+ validateRouterOsScript,
54
+ } from "./execute.ts";
39
55
  import {
40
56
  assertOfflineSyntax,
41
57
  isOfflineGateRejection,
@@ -46,6 +62,8 @@ import {
46
62
  createProtocolAdapter,
47
63
  type ProtocolAdapter,
48
64
  type ProtocolApiRequest,
65
+ type ProtocolApiResult,
66
+ type ProtocolStreamReply,
49
67
  plannedProtocols,
50
68
  type RouterOsProtocol,
51
69
  } from "./protocols/index.ts";
@@ -70,7 +88,7 @@ import {
70
88
  toCoreSource,
71
89
  } from "./resolver/index.ts";
72
90
 
73
- export const apiOutputFormats = ["json", "yaml", "text"] as const;
91
+ export const apiOutputFormats = ["json", "ndjson", "yaml", "text"] as const;
74
92
  export type ApiOutputFormat = (typeof apiOutputFormats)[number];
75
93
 
76
94
  /** HTTP methods `api` honors literally against RouterOS's REST verb map. */
@@ -95,7 +113,7 @@ export interface ApiRequest {
95
113
  data?: string;
96
114
  /** `--input` raw JSON body (file/stdin content already read). Mutually exclusive with `fields` / `data`. */
97
115
  inputBody?: string;
98
- /** `--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. */
99
117
  query?: readonly string[];
100
118
  /** `--raw-query` verbatim RouterOS query words (no leading `?`). */
101
119
  rawQuery?: readonly string[];
@@ -107,11 +125,15 @@ export interface ApiRequest {
107
125
  * error contract (#154).
108
126
  */
109
127
  raw?: boolean;
110
- /** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
111
- listen?: boolean;
112
- /** `--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. */
113
135
  duration?: string;
114
- /** `--count`: bound a `--listen` stream to N frames (later phase). */
136
+ /** `--count`: bound a native reply stream to N rows. */
115
137
  count?: number;
116
138
  via?: string;
117
139
  host?: string;
@@ -154,7 +176,7 @@ export interface NormalizedApiEndpoint {
154
176
  path: string;
155
177
  /** Trailing `*id` segment, when the endpoint addressed one row. */
156
178
  id?: string;
157
- /** 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`. */
158
180
  listen: boolean;
159
181
  }
160
182
 
@@ -167,7 +189,10 @@ export interface ApiRequestSummary {
167
189
  /** `null` when the method was invalid and no verb could be mapped (error path). */
168
190
  verb: ApiVerb | null;
169
191
  write: boolean;
192
+ /** The request is the menu's `listen` command (a `/listen` endpoint). */
170
193
  listen: boolean;
194
+ /** Replies are delivered incrementally (`--stream`, or implied by `listen`). */
195
+ stream: boolean;
171
196
  yes: boolean;
172
197
  validate: boolean;
173
198
  raw: boolean;
@@ -176,20 +201,41 @@ export interface ApiRequestSummary {
176
201
  proplist?: readonly string[];
177
202
  }
178
203
 
179
- /** Why a `--stream` follow ended. */
204
+ /** Why an incremental native command stream ended. */
180
205
  export type ApiStreamStopReason =
206
+ | "completed"
207
+ | "routeros-error"
181
208
  | "count-reached"
182
209
  | "duration-elapsed"
183
210
  | "interrupted"
184
211
  | "transport-error";
185
212
 
186
- /** 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
+ */
187
222
  export type ApiStreamMeta =
188
- | { kind: "frame"; index: number }
223
+ | { kind: "notice" }
224
+ | {
225
+ kind: "frame";
226
+ index: number;
227
+ reply: "re" | "empty";
228
+ afterStop?: true;
229
+ }
189
230
  | {
190
231
  kind: "summary";
191
232
  stopReason: ApiStreamStopReason;
233
+ /** Every reply line: `rows + empty`. */
192
234
  frames: number;
235
+ /** `!re` rows. `--count` counts these. */
236
+ rows: number;
237
+ /** `!empty` replies. */
238
+ empty: number;
193
239
  durationMs: number;
194
240
  };
195
241
 
@@ -197,7 +243,11 @@ export type ApiStreamMeta =
197
243
  export interface ApiStreamSummary {
198
244
  stopReason: ApiStreamStopReason;
199
245
  frames: number;
246
+ rows: number;
247
+ empty: number;
200
248
  durationMs: number;
249
+ /** Attributes from a received terminal `!done` (for example `ret`). */
250
+ done?: Record<string, string>;
201
251
  }
202
252
 
203
253
  export interface ApiOperationMeta {
@@ -227,14 +277,25 @@ export interface ResolvedApiRequest {
227
277
  verb: ApiVerb;
228
278
  /** A `/execute` script-run (CLI string, not a path) — inspect gate is not-applicable. */
229
279
  scriptMode: boolean;
280
+ /** The menu's `listen` command was requested (a `/listen` endpoint). */
230
281
  listen: boolean;
282
+ /** Deliver replies incrementally (`--stream`, or implied by `listen`). */
283
+ stream: boolean;
231
284
  /** Resolved JSON body (from `-f` / `-d` / `--input`). */
232
285
  body: Record<string, string>;
233
- /** 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
+ */
234
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[];
235
296
  proplist: readonly string[];
236
297
  raw: boolean;
237
- /** `--count`: stop a `--stream` after N frames. */
298
+ /** `--count`: stop a `--stream` after N `!re` rows (`!empty` does not count). */
238
299
  count?: number;
239
300
  /** `--duration` parsed to ms: stop a `--stream` after this wall-clock window. */
240
301
  durationMs?: number;
@@ -289,10 +350,16 @@ export async function apiEnvelope(
289
350
  }
290
351
 
291
352
  export async function runResolvedApi(
292
- resolved: ResolvedApiRequest,
353
+ request: ResolvedApiRequest,
293
354
  trace: ValidationTrace = {},
294
355
  ): Promise<ApiSuccessEnvelope> {
295
- assertListenCapability(resolved);
356
+ let resolved = request;
357
+ assertStreamCapability(resolved);
358
+ // Validate the complete request shape before constructing a backend or
359
+ // running either validation stage. In particular, `/execute` must reject
360
+ // extra body fields deterministically instead of letting live `:parse`
361
+ // surface an unrelated transport or authentication failure first.
362
+ const protocolRequest = buildProtocolApiRequest(resolved);
296
363
  const backend = createProtocolAdapter({
297
364
  protocol: resolved.via.value,
298
365
  host: resolved.target.host,
@@ -309,18 +376,28 @@ export async function runResolvedApi(
309
376
  let validation: EnvelopeValidationMeta;
310
377
  let tips: Tip[] = [];
311
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.
312
381
  if (resolved.validate.value) {
313
382
  const result = await validateApiRequest(resolved, backend, trace);
314
383
  validation = result.validation;
384
+ trace.validation = validation;
315
385
  tips = result.tips;
386
+ resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
316
387
  } else {
317
388
  validation = disabledApiValidationMeta(resolved);
389
+ trace.validation = validation;
318
390
  }
319
- // Recorded BEFORE the request, so a transport drop or RouterOS fault below
320
- // reports the gate that actually settled rather than a guess.
321
- trace.validation = validation;
322
391
 
323
- const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
392
+ const result = await backend.apiRequest(
393
+ resolved === request
394
+ ? protocolRequest
395
+ : buildProtocolApiRequest(resolved),
396
+ );
397
+ const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
398
+ if (routerOsFailure) {
399
+ throw routerOsFailure;
400
+ }
324
401
  return {
325
402
  ok: true,
326
403
  data: result.data,
@@ -334,27 +411,58 @@ export async function runResolvedApi(
334
411
  }
335
412
 
336
413
  /**
337
- * The open-ended `--stream` follow path: an async generator yielding one
338
- * {@link ApiEnvelope} per `/listen` change frame, then a terminating summary
339
- * envelope (`data` = {@link ApiStreamSummary}). Open-ended follow is native-api
340
- * only — a `rest-api` (or otherwise unresolved) transport yields a single error
341
- * envelope. `--count`/`--duration` and `externalSignal` (Ctrl-C) bound it.
342
- * Errors before the first frame (resolution, validation, confirmation,
343
- * capability) yield one error envelope and stop — the CLI keys its exit code on
344
- * that first envelope. `onListening` fires once the follow is established on the
345
- * wire (a real barrier for callers that must act only after that point).
414
+ * A `/execute` run that RouterOS *transported* successfully but *rejected* at
415
+ * runtime.
416
+ *
417
+ * `as-string` makes the console's own text the reply body, so a rejected
418
+ * script comes back as an ordinary 200/`!done` carrying `no such item`. Both
419
+ * adapters surface that text as `data`, so without this the caller-addressed
420
+ * `/execute` would report a failure as `ok: true` — exactly the shape
421
+ * `execute` normalizes in `routerOsFailureFromResult`. Structured path
422
+ * requests are untouched: their faults already arrive as HTTP >=400 or a
423
+ * `!trap`, and their `data` is a record, not console text.
346
424
  */
347
- export async function* apiListen(
425
+ function apiRouterOsFailureFromResult(
426
+ resolved: ResolvedApiRequest,
427
+ result: ProtocolApiResult,
428
+ ): CentrsError | undefined {
429
+ if (!resolved.scriptMode || typeof result.data !== "string") {
430
+ return undefined;
431
+ }
432
+ const via = resolved.via.value;
433
+ return mapRouterOsResultError(result.data, {
434
+ transport:
435
+ via === "rest-api" || via === "mac-telnet" || via === "ssh"
436
+ ? via
437
+ : "native-api",
438
+ context: { via },
439
+ });
440
+ }
441
+
442
+ /**
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).
454
+ */
455
+ export async function* apiStream(
348
456
  request: ApiRequest,
349
457
  env: Record<string, string | undefined> = Bun.env,
350
458
  externalSignal?: AbortSignal,
351
459
  onListening?: () => void,
352
460
  ): AsyncGenerator<ApiEnvelope, void, void> {
353
- // `apiListen` is inherently the streaming surface, so force `listen` on the
354
- // request: otherwise a caller that omits it (and the `/listen` endpoint form)
355
- // would resolve `via` to the rest-api default and be rejected. An explicit
356
- // `--via rest-api` still surfaces capability-unsupported below.
357
- 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 };
358
466
  let resolved: ResolvedApiRequest | undefined;
359
467
  try {
360
468
  resolved = await resolveApiRequest(streamRequest, env);
@@ -362,15 +470,16 @@ export async function* apiListen(
362
470
  if (resolved.via.value !== "native-api") {
363
471
  throw new CentrsError({
364
472
  code: "transport/capability-unsupported",
365
- summary: "REST cannot follow an open-ended `--stream` (60s cap).",
473
+ summary: "Incremental `--stream` replies require native-api.",
366
474
  remediation:
367
- "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
368
- 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" },
369
477
  });
370
478
  }
371
479
  } catch (error) {
480
+ // Every failure here comes before stage 1, so an empty trace says so.
372
481
  yield resolved
373
- ? buildApiErrorEnvelopeFromResolved(resolved, error)
482
+ ? buildApiErrorEnvelopeFromResolved(resolved, error, {})
374
483
  : buildApiErrorEnvelope(streamRequest, error, env);
375
484
  return;
376
485
  }
@@ -378,35 +487,62 @@ export async function* apiListen(
378
487
  }
379
488
 
380
489
  async function* streamResolvedApi(
381
- resolved: ResolvedApiRequest,
490
+ request: ResolvedApiRequest,
382
491
  externalSignal?: AbortSignal,
383
492
  onListening?: () => void,
384
493
  ): AsyncGenerator<ApiEnvelope, void, void> {
385
- const backend = adapterForResolved(resolved);
494
+ const backend = adapterForResolved(request);
386
495
 
496
+ let resolved = request;
387
497
  let validation: EnvelopeValidationMeta;
498
+ const trace: ValidationTrace = {};
388
499
  try {
389
- validation = resolved.validate.value
390
- ? (await validateApiRequest(resolved, backend)).validation
391
- : 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
+ }
392
509
  } catch (error) {
393
510
  await backend.close();
394
- yield buildApiErrorEnvelopeFromResolved(resolved, error);
511
+ yield buildApiErrorEnvelopeFromResolved(resolved, error, trace);
395
512
  return;
396
513
  }
397
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);
398
518
  const controller = new AbortController();
399
519
  const startedAt = Date.now();
400
- let frames = 0;
520
+ const counts = { frames: 0, rows: 0, empty: 0 };
401
521
  let stopReason: ApiStreamStopReason | undefined;
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
+ : [];
402
534
  let durationTimer: ReturnType<typeof setTimeout> | undefined;
535
+ // The first stop cause wins: the cancel grace window keeps the loop alive
536
+ // after a stop, so a later timer or frame must not relabel it.
403
537
  const onExternalAbort = (): void => {
538
+ if (done !== undefined) return;
404
539
  stopReason ??= "interrupted";
540
+ clearTimeout(durationTimer);
405
541
  controller.abort();
406
542
  };
407
543
  if (resolved.durationMs !== undefined) {
408
544
  durationTimer = setTimeout(() => {
409
- stopReason = "duration-elapsed";
545
+ stopReason ??= "duration-elapsed";
410
546
  controller.abort();
411
547
  }, resolved.durationMs);
412
548
  }
@@ -419,39 +555,69 @@ async function* streamResolvedApi(
419
555
  }
420
556
 
421
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
+ }
422
563
  const protocolRequest = buildProtocolApiRequest(resolved);
423
- for await (const record of backend.listen(protocolRequest, {
564
+ for await (const reply of backend.stream(protocolRequest, {
424
565
  signal: controller.signal,
425
566
  onListening,
567
+ onCancelUnacknowledged: () => {
568
+ cancelUnacknowledged = true;
569
+ },
570
+ onDone: (attributes) => {
571
+ done = attributes;
572
+ clearTimeout(durationTimer);
573
+ },
426
574
  })) {
427
- frames += 1;
428
- yield streamFrameEnvelope(resolved, validation, record, frames);
429
- if (resolved.count !== undefined && frames >= resolved.count) {
430
- stopReason = "count-reached";
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
+ ) {
586
+ stopReason ??= "count-reached";
431
587
  break;
432
588
  }
433
589
  }
590
+ const routerOsFailure = apiRouterOsFailureFromResult(resolved, {
591
+ data: done?.["ret"],
592
+ });
593
+ if (routerOsFailure) throw routerOsFailure;
434
594
  yield streamSummaryEnvelope(
435
595
  resolved,
436
596
  validation,
437
- stopReason ?? "interrupted",
438
- frames,
597
+ stopReason ?? "completed",
598
+ counts,
439
599
  Date.now() - startedAt,
600
+ tips,
601
+ cancellationWarnings(),
602
+ done,
440
603
  );
441
604
  } catch (error) {
442
- // A mid-stream failure is itself a frame, tagged `stream.kind="frame"` so
443
- // consumers that key off `meta.operation.stream` still see it in the stream
444
- // (not as a stray non-stream envelope); the summary then closes with
445
- // `transport-error` (the CLI's exit code keys on whether it *started*
446
- // cleanly).
447
- yield streamErrorFrameEnvelope(resolved, error, frames + 1);
448
- yield streamSummaryEnvelope(
449
- resolved,
450
- validation,
451
- "transport-error",
452
- frames,
453
- Date.now() - startedAt,
454
- );
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;
455
621
  } finally {
456
622
  if (durationTimer !== undefined) {
457
623
  clearTimeout(durationTimer);
@@ -464,52 +630,114 @@ async function* streamResolvedApi(
464
630
  function streamFrameEnvelope(
465
631
  resolved: ResolvedApiRequest,
466
632
  validation: EnvelopeValidationMeta,
467
- record: Record<string, unknown>,
633
+ reply: ProtocolStreamReply,
468
634
  index: number,
469
635
  ): ApiSuccessEnvelope {
470
- const meta = metaFromResolved(resolved, validation, record);
471
- meta.operation.stream = { kind: "frame", index };
472
- 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 };
473
645
  }
474
646
 
475
- /**
476
- * A mid-stream failure rendered as a stream frame: the error envelope carries
477
- * `meta.operation.stream.kind="frame"` (index after the last good frame) so
478
- * stream consumers can place it on the timeline rather than mistaking it for a
479
- * non-stream envelope.
480
- */
481
- function streamErrorFrameEnvelope(
647
+ function streamNoticeEnvelope(
482
648
  resolved: ResolvedApiRequest,
483
- error: unknown,
484
- index: number,
485
- ): ApiErrorEnvelope {
486
- const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
487
- if (envelope.meta.operation) {
488
- envelope.meta.operation.stream = { kind: "frame", index };
489
- }
490
- 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 };
491
655
  }
492
656
 
493
657
  function streamSummaryEnvelope(
494
658
  resolved: ResolvedApiRequest,
495
659
  validation: EnvelopeValidationMeta,
496
660
  stopReason: ApiStreamStopReason,
497
- frames: number,
661
+ counts: { frames: number; rows: number; empty: number },
498
662
  durationMs: number,
663
+ tips: Tip[],
664
+ streamWarnings: readonly Warning[] = [],
665
+ done?: Record<string, string>,
499
666
  ): ApiSuccessEnvelope {
500
- 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
+ };
501
673
  const meta = metaFromResolved(resolved, validation, summary);
502
- meta.operation.objectCount = frames;
503
- 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
+ };
504
681
  return {
505
682
  ok: true,
506
683
  data: summary,
507
- warnings: [...resolved.warnings],
508
- tips: [],
684
+ warnings: [...resolved.warnings, ...streamWarnings],
685
+ tips,
509
686
  meta,
510
687
  };
511
688
  }
512
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
+
513
741
  function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
514
742
  return createProtocolAdapter({
515
743
  protocol: resolved.via.value,
@@ -555,7 +783,9 @@ export async function resolveApiRequest(
555
783
  const normalized = normalizeApiEndpoint(request.endpoint);
556
784
  const method = parseApiMethod(request.method);
557
785
  const verb = mapMethodToVerb(method);
558
- 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;
559
789
  // `/execute` (root, single token) run via POST is the script surface — a CLI
560
790
  // string, not a menu path. A GET, a nested path, or any other menu that merely
561
791
  // ends in `execute` stays a normal path request validated through inspect.
@@ -574,10 +804,59 @@ export async function resolveApiRequest(
574
804
  context: { method, path: normalized.path },
575
805
  });
576
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
+ }
577
818
  const raw = request.raw ?? false;
578
819
  const body = buildApiBody(request);
579
- 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
+ ];
580
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
+ }
581
860
 
582
861
  // A quickchr target is a named-live-provider: host/port/auth come from the
583
862
  // live descriptor, bypassing CDB, `__default__`, and MAC resolution.
@@ -600,7 +879,7 @@ export async function resolveApiRequest(
600
879
  env,
601
880
  config,
602
881
  )));
603
- const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
882
+ const via = resolveApiProtocol(request, env, stream, cdbResolution, config);
604
883
  const format = resolveApiFormat(request, env, config);
605
884
  // `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
606
885
  // explicit `--validate` and above every ambient source, so:
@@ -636,6 +915,19 @@ export async function resolveApiRequest(
636
915
  cdbResolution?.overrides.validate,
637
916
  config,
638
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
+ }
639
931
  const timeoutMs = resolveApiTimeout(
640
932
  request.timeout,
641
933
  env,
@@ -716,8 +1008,11 @@ export async function resolveApiRequest(
716
1008
  verb,
717
1009
  scriptMode,
718
1010
  listen,
1011
+ stream,
719
1012
  body,
720
1013
  query,
1014
+ structuredQuery,
1015
+ rawQuery,
721
1016
  proplist,
722
1017
  raw,
723
1018
  count: request.count,
@@ -856,38 +1151,36 @@ function parseJsonBody(raw: string, flag: string): Record<string, string> {
856
1151
  }
857
1152
 
858
1153
  /**
859
- * Build REST `.query` words (no `?` prefix). Structured `--query` words first, in
860
- * order, then verbatim `--raw-query` words. Mapping is CHR-grounded
861
- * (`commands/api/AGENTS.md`): `name=value` (eq), `name!=value` → `name=value`,
862
- * `#!` (eq then NOT-top), `name>value`/`name<value` → `>name=value`/`<name=value`,
863
- * 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.
864
1158
  */
865
1159
  export function buildApiQuery(request: ApiRequest): string[] {
866
- const words: string[] = [];
867
- for (const expression of request.query ?? []) {
868
- words.push(...queryExpressionToWords(expression));
869
- }
870
- for (const raw of request.rawQuery ?? []) {
871
- words.push(raw);
872
- }
873
- return words;
1160
+ const structured =
1161
+ (request.query?.length ?? 0) > 0
1162
+ ? compileQueryWords(parseQueries(request.query ?? []), new Set())
1163
+ : [];
1164
+ return [...structured, ...(request.rawQuery ?? [])];
874
1165
  }
875
1166
 
876
- function queryExpressionToWords(expression: string): string[] {
877
- const ne = expression.indexOf("!=");
878
- if (ne >= 0) {
879
- return [`${expression.slice(0, ne)}=${expression.slice(ne + 2)}`, "#!"];
880
- }
881
- const gt = expression.indexOf(">");
882
- if (gt >= 0) {
883
- return [`>${expression.slice(0, gt)}=${expression.slice(gt + 1)}`];
884
- }
885
- const lt = expression.indexOf("<");
886
- if (lt >= 0) {
887
- return [`<${expression.slice(0, lt)}=${expression.slice(lt + 1)}`];
888
- }
889
- // `name=value` (eq) or bare `name` (has-property) pass through verbatim.
890
- 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
+ };
891
1184
  }
892
1185
 
893
1186
  function buildApiProplist(request: ApiRequest): string[] {
@@ -913,6 +1206,9 @@ export function buildProtocolApiRequest(
913
1206
  if (resolved.id) {
914
1207
  request.id = resolved.id;
915
1208
  }
1209
+ if (resolved.listen) {
1210
+ request.listen = true;
1211
+ }
916
1212
  if (resolved.scriptMode) {
917
1213
  const { script, ...extra } = resolved.body;
918
1214
  if (typeof script !== "string" || script.trim().length === 0) {
@@ -944,7 +1240,7 @@ export function buildProtocolApiRequest(
944
1240
  request.attributes = resolved.body;
945
1241
  }
946
1242
  }
947
- if (resolved.verb === "print") {
1243
+ if (resolved.verb === "print" || resolved.verb === "run") {
948
1244
  if (resolved.query.length > 0) {
949
1245
  request.query = resolved.query;
950
1246
  }
@@ -955,12 +1251,15 @@ export function buildProtocolApiRequest(
955
1251
  return request;
956
1252
  }
957
1253
 
958
- /** The api device-stage validator: structured input is inspected, never `:parse`d. */
1254
+ /** The api device-stage validator for structured path input. */
959
1255
  const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
1256
+ const API_SCRIPT_DEVICE_STAGE_SOURCE = ":put [:parse]";
960
1257
 
961
1258
  interface ApiValidationResult {
962
1259
  validation: EnvelopeValidationMeta;
963
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;
964
1263
  }
965
1264
 
966
1265
  /**
@@ -976,11 +1275,9 @@ async function validateApiRequest(
976
1275
  trace: ValidationTrace = {},
977
1276
  ): Promise<ApiValidationResult> {
978
1277
  if (resolved.scriptMode) {
979
- // `/execute` carries a CLI string, so `/console/inspect` has nothing to
980
- // inspect — which until GH#354 meant the script was sent with NO gate at
981
- // all (`syntax: false`, `semantic: "not-applicable"`, `result: "passed"`).
982
- // The offline analyzer is the whole gate here: a syntax fault is now a
983
- // byte span and no round trip, where before it reached the device.
1278
+ // `/execute` carries a CLI string, so use the same offline -> live `:parse`
1279
+ // pipeline as `execute`. `/console/inspect` remains inapplicable because
1280
+ // there is no structured menu path/body to inspect.
984
1281
  const script = scriptOf(resolved);
985
1282
  const offline = script
986
1283
  ? assertOfflineSyntax(script, {
@@ -989,6 +1286,9 @@ async function validateApiRequest(
989
1286
  })
990
1287
  : undefined;
991
1288
  trace.offlineVerdict = offline?.verdict;
1289
+ if (script !== undefined) {
1290
+ await validateRouterOsScript(script, backend, resolved.via.value);
1291
+ }
992
1292
  const offlineReason =
993
1293
  offline === undefined
994
1294
  ? "no `script` field to analyze"
@@ -998,9 +1298,12 @@ async function validateApiRequest(
998
1298
  return {
999
1299
  validation: {
1000
1300
  enabled: true,
1001
- source: offline === undefined ? "not-applicable" : OFFLINE_GATE_SOURCE,
1301
+ source:
1302
+ offline === undefined
1303
+ ? "not-applicable"
1304
+ : API_SCRIPT_DEVICE_STAGE_SOURCE,
1002
1305
  result: "passed",
1003
- syntax: offline !== undefined,
1306
+ syntax: script !== undefined,
1004
1307
  semantic: "not-applicable",
1005
1308
  stages: [
1006
1309
  {
@@ -1011,14 +1314,16 @@ async function validateApiRequest(
1011
1314
  },
1012
1315
  {
1013
1316
  stage: "device",
1014
- source: "/console/inspect request=child",
1015
- result: "skipped",
1016
- reason:
1017
- "`/execute` is a CLI string, not a menu path; RouterOS re-validates it on the run",
1317
+ source: API_SCRIPT_DEVICE_STAGE_SOURCE,
1318
+ result: script === undefined ? "skipped" : "passed",
1319
+ ...(script === undefined
1320
+ ? { reason: "no `script` field to parse" }
1321
+ : {}),
1018
1322
  },
1019
1323
  ],
1020
1324
  },
1021
1325
  tips: [],
1326
+ queryMenu: resolved.path,
1022
1327
  };
1023
1328
  }
1024
1329
 
@@ -1091,6 +1396,7 @@ async function validateApiRequest(
1091
1396
  semantic: true,
1092
1397
  },
1093
1398
  tips,
1399
+ queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
1094
1400
  };
1095
1401
  }
1096
1402
 
@@ -1135,6 +1441,7 @@ async function validateApiRequest(
1135
1441
  availableAttributes: available,
1136
1442
  },
1137
1443
  tips,
1444
+ queryMenu: resolved.path,
1138
1445
  };
1139
1446
  }
1140
1447
 
@@ -1147,6 +1454,7 @@ async function validateApiRequest(
1147
1454
  semantic: true,
1148
1455
  },
1149
1456
  tips,
1457
+ queryMenu: resolved.path,
1150
1458
  };
1151
1459
  }
1152
1460
 
@@ -1208,38 +1516,39 @@ async function assertApiWriteConfirmed(
1208
1516
  });
1209
1517
  }
1210
1518
 
1211
- function assertListenCapability(resolved: ResolvedApiRequest): void {
1212
- if (!resolved.listen) {
1519
+ function assertStreamCapability(resolved: ResolvedApiRequest): void {
1520
+ if (!resolved.stream) {
1213
1521
  return;
1214
1522
  }
1215
1523
  if (resolved.via.value === "rest-api") {
1216
1524
  throw new CentrsError({
1217
1525
  code: "transport/capability-unsupported",
1218
- summary: "REST cannot follow an open-ended `--stream` (60s cap).",
1526
+ summary: "Incremental `--stream` replies require native-api.",
1219
1527
  remediation:
1220
- "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
1221
- 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" },
1222
1530
  });
1223
1531
  }
1224
1532
  throw new CentrsError({
1225
1533
  code: "input/invalid-command",
1226
- 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.",
1227
1536
  remediation:
1228
- "Consume the stream via `apiListen()` (library) or `centrs api … --stream` (CLI); both yield an NDJSON envelope per change frame plus a final summary.",
1229
- 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" },
1230
1539
  });
1231
1540
  }
1232
1541
 
1233
1542
  function resolveApiProtocol(
1234
1543
  request: ApiRequest,
1235
1544
  env: Record<string, string | undefined>,
1236
- listen: boolean,
1545
+ stream: boolean,
1237
1546
  cdb?: CdbResolution,
1238
1547
  config: Record<string, string | undefined> = {},
1239
1548
  ): ResolvedSetting<RouterOsProtocol> {
1240
- // A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
1241
- // REST stays the default for one-shot calls.
1242
- 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";
1243
1552
  const via = resolveStringSetting(
1244
1553
  request.via,
1245
1554
  env,
@@ -1383,7 +1692,9 @@ export function buildApiErrorEnvelope(
1383
1692
  write: parsedMethod
1384
1693
  ? isApiMutating(parsedMethod, reportedPath)
1385
1694
  : false,
1386
- listen: request.listen ?? false,
1695
+ listen: safeNormalizeListen(request.endpoint),
1696
+ stream:
1697
+ (request.stream ?? false) || safeNormalizeListen(request.endpoint),
1387
1698
  yes: request.yes ?? false,
1388
1699
  validate: summarizedValidate(request),
1389
1700
  raw: request.raw ?? false,
@@ -1441,27 +1752,57 @@ function failedApiValidationMeta(
1441
1752
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
1442
1753
  {
1443
1754
  stage: "device",
1444
- source: API_DEVICE_STAGE_SOURCE,
1755
+ source: apiDeviceStageSource(resolved),
1445
1756
  result: "skipped",
1446
1757
  reason: "offline analysis rejected the script; no connection opened",
1447
1758
  },
1448
1759
  ],
1449
1760
  };
1450
1761
  }
1451
- // `stages` is only asserted when a `validation/*` error identifies the device
1452
- // stage as the rejecter. Everything downstream — a RouterOS fault on the
1453
- // request, a transport drop — passed the gate and is served by
1454
- // `trace.validation` before this function is ever reached.
1455
- 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
+ }
1456
1792
  return {
1457
1793
  enabled: true,
1458
- source: "/console/inspect",
1459
- result: "failed",
1460
- syntax: false,
1461
- semantic: resolved.scriptMode ? "not-applicable" : false,
1462
- ...(deviceRejected
1463
- ? { stages: [offlineApiStage(resolved, trace), deviceStageFailed()] }
1464
- : {}),
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
+ ],
1465
1806
  };
1466
1807
  }
1467
1808
 
@@ -1494,10 +1835,18 @@ function offlineApiStage(
1494
1835
  };
1495
1836
  }
1496
1837
 
1497
- function deviceStageFailed(): EnvelopeValidationStage {
1838
+ function apiDeviceStageSource(resolved: ResolvedApiRequest): string {
1839
+ return resolved.scriptMode
1840
+ ? API_SCRIPT_DEVICE_STAGE_SOURCE
1841
+ : API_DEVICE_STAGE_SOURCE;
1842
+ }
1843
+
1844
+ function deviceStageFailed(
1845
+ resolved: ResolvedApiRequest,
1846
+ ): EnvelopeValidationStage {
1498
1847
  return {
1499
1848
  stage: "device",
1500
- source: API_DEVICE_STAGE_SOURCE,
1849
+ source: apiDeviceStageSource(resolved),
1501
1850
  result: "failed",
1502
1851
  };
1503
1852
  }
@@ -1518,7 +1867,11 @@ function disabledApiValidationMeta(
1518
1867
  semantic: resolved.scriptMode ? "not-applicable" : false,
1519
1868
  stages: [
1520
1869
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
1521
- { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "skipped" },
1870
+ {
1871
+ stage: "device",
1872
+ source: apiDeviceStageSource(resolved),
1873
+ result: "skipped",
1874
+ },
1522
1875
  ],
1523
1876
  };
1524
1877
  }
@@ -1551,12 +1904,16 @@ export function renderApiEnvelope(
1551
1904
  switch (format) {
1552
1905
  case "json":
1553
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);
1554
1911
  case "yaml":
1555
1912
  return toYaml(envelope);
1556
1913
  case "text":
1557
1914
  return envelope.ok
1558
1915
  ? renderApiSuccessText(envelope, options)
1559
- : renderApiErrorText(envelope, options);
1916
+ : formatCentrsErrorText(envelope.error, options);
1560
1917
  default:
1561
1918
  return exhaustiveFormat(format);
1562
1919
  }
@@ -1586,20 +1943,27 @@ export function renderApiStreamLine(
1586
1943
  }
1587
1944
  // A streamed error must stay one line too — never the verbose blank-line +
1588
1945
  // pretty-printed context block that `renderApiErrorText` emits.
1589
- const error = envelope.error;
1590
- return error.remediation
1591
- ? `[${error.code}] ${error.summary} — Fix: ${error.remediation}`
1592
- : `[${error.code}] ${error.summary}`;
1946
+ return formatCentrsErrorLine(envelope.error);
1593
1947
  }
1594
- // json and yaml both stream as NDJSON: one compact envelope object per line.
1595
- // (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.)
1596
1951
  return JSON.stringify(envelope);
1597
1952
  }
1598
1953
 
1599
1954
  function renderApiStreamFrameText(envelope: ApiSuccessEnvelope): string {
1600
1955
  const stream = envelope.meta.operation?.stream;
1601
1956
  if (stream?.kind === "summary") {
1602
- 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`;
1603
1967
  }
1604
1968
  const index = stream?.kind === "frame" ? stream.index : 0;
1605
1969
  return `${index}\t${JSON.stringify(envelope.data)}`;
@@ -1641,21 +2005,12 @@ function renderApiSuccessText(
1641
2005
  return lines.join("\n");
1642
2006
  }
1643
2007
 
1644
- function renderApiErrorText(
1645
- envelope: ApiErrorEnvelope,
1646
- options: { verbose?: boolean },
1647
- ): string {
1648
- const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
1649
- if (envelope.error.remediation) {
1650
- lines.push(`Fix: ${envelope.error.remediation}`);
1651
- }
1652
- if (options.verbose && envelope.error.context) {
1653
- lines.push("", JSON.stringify(envelope.error.context, null, 2));
1654
- }
1655
- return lines.join("\n");
1656
- }
1657
-
1658
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
+ }
1659
2014
  if (!request.endpoint || request.endpoint.trim().length === 0) {
1660
2015
  throw new CentrsError({
1661
2016
  code: "input/invalid-command",
@@ -1675,6 +2030,31 @@ export function validateApiRequestShape(request: ApiRequest): void {
1675
2030
  }
1676
2031
  }
1677
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
+
1678
2058
  export function parseApiMethod(method: string | undefined): ApiMethod {
1679
2059
  if (method === undefined) {
1680
2060
  return "GET";
@@ -1702,7 +2082,7 @@ export function apiRequestSummaryFromRequest(
1702
2082
  ): ApiRequestSummary {
1703
2083
  const normalized = normalizeApiEndpoint(request.endpoint);
1704
2084
  const parsed = tryParseApiMethod(request.method);
1705
- const listen = (request.listen ?? false) || normalized.listen;
2085
+ const listen = normalized.listen;
1706
2086
  return {
1707
2087
  endpoint: request.endpoint,
1708
2088
  path: normalized.path,
@@ -1711,15 +2091,25 @@ export function apiRequestSummaryFromRequest(
1711
2091
  verb: parsed ? mapMethodToVerb(parsed) : null,
1712
2092
  write: parsed ? isApiMutating(parsed, normalized.path) : false,
1713
2093
  listen,
2094
+ stream: (request.stream ?? false) || listen,
1714
2095
  yes: request.yes ?? false,
1715
2096
  validate: summarizedValidate(request),
1716
2097
  raw: request.raw ?? false,
1717
2098
  format: resolveErrorFormat(request, env),
1718
- query: buildApiQuery(request),
2099
+ query: summaryQuery(request),
1719
2100
  proplist: buildApiProplist(request),
1720
2101
  };
1721
2102
  }
1722
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
+
1723
2113
  /** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
1724
2114
  function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1725
2115
  if (method === undefined) {
@@ -1731,6 +2121,24 @@ function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1731
2121
  : undefined;
1732
2122
  }
1733
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
+
1734
2142
  function safeNormalizePath(endpoint: string): string {
1735
2143
  try {
1736
2144
  return normalizeApiEndpoint(endpoint).path;
@@ -1826,6 +2234,7 @@ function apiRequestSummary(resolved: ResolvedApiRequest): ApiRequestSummary {
1826
2234
  verb: resolved.verb,
1827
2235
  write: isApiMutating(resolved.method, resolved.path),
1828
2236
  listen: resolved.listen,
2237
+ stream: resolved.stream,
1829
2238
  yes: resolved.yes,
1830
2239
  validate: resolved.validate.value,
1831
2240
  raw: resolved.raw,