@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.
- package/CHANGELOG.md +256 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +123 -41
- package/commands/api/examples.md +205 -17
- package/commands/devices/README.md +11 -3
- package/commands/execute/README.md +31 -3
- package/commands/execute/examples.md +136 -1
- package/commands/explain/README.md +30 -4
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +422 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +28 -20
- package/docs/CONSTITUTION.md +30 -10
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +511 -186
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +223 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +56 -1
- package/src/core/inspect.ts +43 -5
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +1 -1
- package/src/devices.ts +6 -9
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +13 -8
- package/src/execute.ts +106 -65
- package/src/explain/catalog.ts +58 -10
- package/src/explain/menus.ts +23 -2
- package/src/explain.ts +6 -6
- package/src/index.ts +29 -1
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +14 -17
- package/src/protocols/adapter.ts +224 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +6 -3
- package/src/protocols/native-api.ts +223 -70
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +53 -7
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +732 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- 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
|
|
15
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
353
|
+
request: ResolvedApiRequest,
|
|
299
354
|
trace: ValidationTrace = {},
|
|
300
355
|
): Promise<ApiSuccessEnvelope> {
|
|
301
|
-
|
|
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(
|
|
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
|
|
382
|
-
*
|
|
383
|
-
* envelope (`data` = {@link ApiStreamSummary}).
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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*
|
|
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
|
-
// `
|
|
398
|
-
// request: otherwise a caller that omits it
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
const streamRequest: ApiRequest = { ...request,
|
|
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: "
|
|
473
|
+
summary: "Incremental `--stream` replies require native-api.",
|
|
410
474
|
remediation:
|
|
411
|
-
"
|
|
412
|
-
context: { via: resolved.via.value, capability: "
|
|
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
|
-
|
|
490
|
+
request: ResolvedApiRequest,
|
|
426
491
|
externalSignal?: AbortSignal,
|
|
427
492
|
onListening?: () => void,
|
|
428
493
|
): AsyncGenerator<ApiEnvelope, void, void> {
|
|
429
|
-
const backend = adapterForResolved(
|
|
494
|
+
const backend = adapterForResolved(request);
|
|
430
495
|
|
|
496
|
+
let resolved = request;
|
|
431
497
|
let validation: EnvelopeValidationMeta;
|
|
498
|
+
const trace: ValidationTrace = {};
|
|
432
499
|
try {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
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 ?? "
|
|
489
|
-
|
|
597
|
+
stopReason ?? "completed",
|
|
598
|
+
counts,
|
|
490
599
|
Date.now() - startedAt,
|
|
491
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
633
|
+
reply: ProtocolStreamReply,
|
|
528
634
|
index: number,
|
|
529
635
|
): ApiSuccessEnvelope {
|
|
530
|
-
const
|
|
531
|
-
meta
|
|
532
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
):
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
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 = {
|
|
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 =
|
|
564
|
-
meta.operation.stream = {
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
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
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
|
1279
|
-
if (!resolved.
|
|
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: "
|
|
1526
|
+
summary: "Incremental `--stream` replies require native-api.",
|
|
1286
1527
|
remediation:
|
|
1287
|
-
"
|
|
1288
|
-
context: { via: resolved.via.value, capability: "
|
|
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:
|
|
1534
|
+
summary:
|
|
1535
|
+
"`--stream` yields incremental replies instead of a one-shot `api` result.",
|
|
1294
1536
|
remediation:
|
|
1295
|
-
"Consume the stream via `
|
|
1296
|
-
context: { via: resolved.via.value, capability: "
|
|
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
|
-
|
|
1545
|
+
stream: boolean,
|
|
1304
1546
|
cdb?: CdbResolution,
|
|
1305
1547
|
config: Record<string, string | undefined> = {},
|
|
1306
1548
|
): ResolvedSetting<RouterOsProtocol> {
|
|
1307
|
-
// A
|
|
1308
|
-
// REST stays the default for one-shot calls.
|
|
1309
|
-
const defaultVia: RouterOsProtocol =
|
|
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.
|
|
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
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
|
|
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
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
1679
|
-
// (yaml falls back to JSON — a
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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,
|