@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
package/src/api.ts ADDED
@@ -0,0 +1,1627 @@
1
+ /**
2
+ * `centrs api` — a structured RouterOS API passthrough, modeled on `gh api`.
3
+ *
4
+ * The structured middle of the verb trichotomy (see `commands/api/README.md`):
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
7
+ * 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
+ * hands a normalized {@link ProtocolApiRequest} to the adapter. REST vs native
10
+ * mechanics (id-in-URL vs `=.id=`, `.query` vs `?`-words) live in the adapter.
11
+ *
12
+ * 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.
15
+ */
16
+
17
+ import type {
18
+ CentrsEnvelope,
19
+ CentrsErrorEnvelope,
20
+ CentrsSuccessEnvelope,
21
+ CommonSettingsMeta,
22
+ SettingSource as CoreSettingSource,
23
+ EnvelopeValidationMeta,
24
+ Tip,
25
+ Warning,
26
+ } from "./core/envelope.ts";
27
+ import { buildTip } from "./core/envelope.ts";
28
+ import {
29
+ extractCompletionNames,
30
+ inspectChildren,
31
+ inspectChildrenOrEmpty,
32
+ inspectCompletions,
33
+ isArgumentNode,
34
+ isCommandNode,
35
+ pathTokens,
36
+ } from "./core/inspect.ts";
37
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
38
+ import { promptForWriteConfirmation } from "./execute.ts";
39
+ import {
40
+ type ApiVerb,
41
+ createProtocolAdapter,
42
+ type ProtocolAdapter,
43
+ type ProtocolApiRequest,
44
+ plannedProtocols,
45
+ type RouterOsProtocol,
46
+ } from "./protocols/index.ts";
47
+ import {
48
+ type CdbResolution,
49
+ isIpTransport,
50
+ loadEnvFileDefaults,
51
+ parseDuration,
52
+ parseResolvePolicy,
53
+ type ResolvedAuth,
54
+ type ResolvedSetting,
55
+ type ResolvedTarget,
56
+ resolveAuth,
57
+ resolveBooleanSetting,
58
+ resolveCdb,
59
+ resolveMacTarget,
60
+ resolveStringSetting,
61
+ resolveTarget,
62
+ toCoreSource,
63
+ } from "./resolver/index.ts";
64
+ import { toYaml } from "./retrieve.ts";
65
+
66
+ export const apiOutputFormats = ["json", "yaml", "text"] as const;
67
+ export type ApiOutputFormat = (typeof apiOutputFormats)[number];
68
+
69
+ /** HTTP methods `api` honors literally against RouterOS's REST verb map. */
70
+ export const apiMethods = ["GET", "PUT", "PATCH", "DELETE", "POST"] as const;
71
+ export type ApiMethod = (typeof apiMethods)[number];
72
+
73
+ export interface ApiRequest {
74
+ targetInput?: string;
75
+ /** REST-style endpoint path, leniently normalized (`ip/address`, `/rest/ip/address`, `"ip address"`, `ip/address/*1`, `ip/address/listen`). */
76
+ endpoint: string;
77
+ /** HTTP method (`-X`), default `GET`, case-insensitive. */
78
+ method?: string;
79
+ /** `-f key=value` fields, merged into the JSON body (verbatim string values). */
80
+ fields?: Record<string, string>;
81
+ /** `-d` raw JSON body. Mutually exclusive with `fields` / `inputBody`. */
82
+ data?: string;
83
+ /** `--input` raw JSON body (file/stdin content already read). Mutually exclusive with `fields` / `data`. */
84
+ inputBody?: string;
85
+ /** `--query` / `--filter` expressions (`name=value`, `name!=value`, `name>value`, `name`). */
86
+ query?: readonly string[];
87
+ /** `--raw-query` verbatim RouterOS query words (no leading `?`). */
88
+ rawQuery?: readonly string[];
89
+ /** `--proplist` / `--attribute` property projection. */
90
+ proplist?: readonly string[];
91
+ /** `--raw`: bare RouterOS body passthrough; implies `--validate=false`. */
92
+ raw?: boolean;
93
+ /** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
94
+ listen?: boolean;
95
+ /** `--duration`: bound a `--listen` stream (later phase). */
96
+ duration?: string;
97
+ /** `--count`: bound a `--listen` stream to N frames (later phase). */
98
+ count?: number;
99
+ via?: string;
100
+ host?: string;
101
+ port?: number;
102
+ username?: string;
103
+ password?: string;
104
+ insecure?: boolean;
105
+ timeout?: string | number;
106
+ format?: string;
107
+ validate?: boolean;
108
+ yes?: boolean;
109
+ verbose?: boolean;
110
+ cdbFile?: string;
111
+ cdbPassword?: string;
112
+ /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
113
+ resolve?: string;
114
+ stdinIsTty?: boolean;
115
+ confirm?: (prompt: string) => Promise<boolean>;
116
+ }
117
+
118
+ export interface NormalizedApiEndpoint {
119
+ /** Canonical slash path with the leading `/rest` and any trailing id/`listen` removed. */
120
+ path: string;
121
+ /** Trailing `*id` segment, when the endpoint addressed one row. */
122
+ id?: string;
123
+ /** True when the endpoint's terminal segment was `listen` (infers `--listen`). */
124
+ listen: boolean;
125
+ }
126
+
127
+ export interface ApiRequestSummary {
128
+ endpoint: string;
129
+ path: string;
130
+ id?: string;
131
+ /** The method reported back: a valid {@link ApiMethod}, or the raw string when an invalid `-X` could not be parsed (error path). */
132
+ method: ApiMethod | string;
133
+ /** `null` when the method was invalid and no verb could be mapped (error path). */
134
+ verb: ApiVerb | null;
135
+ write: boolean;
136
+ listen: boolean;
137
+ yes: boolean;
138
+ validate: boolean;
139
+ raw: boolean;
140
+ format: ApiOutputFormat;
141
+ query?: readonly string[];
142
+ proplist?: readonly string[];
143
+ }
144
+
145
+ /** Why a `--stream` follow ended. */
146
+ export type ApiStreamStopReason =
147
+ | "count-reached"
148
+ | "duration-elapsed"
149
+ | "interrupted"
150
+ | "transport-error";
151
+
152
+ /** Per-line marker on `--stream` NDJSON output: a change frame, or the terminating summary. */
153
+ export type ApiStreamMeta =
154
+ | { kind: "frame"; index: number }
155
+ | {
156
+ kind: "summary";
157
+ stopReason: ApiStreamStopReason;
158
+ frames: number;
159
+ durationMs: number;
160
+ };
161
+
162
+ /** The `data` payload of the terminating summary envelope on a `--stream`. */
163
+ export interface ApiStreamSummary {
164
+ stopReason: ApiStreamStopReason;
165
+ frames: number;
166
+ durationMs: number;
167
+ }
168
+
169
+ export interface ApiOperationMeta {
170
+ kind: "api";
171
+ objectCount: number;
172
+ request: ApiRequestSummary;
173
+ auth: {
174
+ username?: string;
175
+ passwordProvided: boolean;
176
+ };
177
+ /** Present only on `--stream` output: the per-frame index or the final summary. */
178
+ stream?: ApiStreamMeta;
179
+ }
180
+
181
+ export type ApiEnvelope = CentrsEnvelope<unknown, ApiOperationMeta>;
182
+ export type ApiSuccessEnvelope = CentrsSuccessEnvelope<
183
+ unknown,
184
+ ApiOperationMeta
185
+ >;
186
+ export type ApiErrorEnvelope = CentrsErrorEnvelope<ApiOperationMeta>;
187
+
188
+ export interface ResolvedApiRequest {
189
+ endpoint: string;
190
+ path: string;
191
+ id?: string;
192
+ method: ApiMethod;
193
+ verb: ApiVerb;
194
+ /** A `/execute` script-run (CLI string, not a path) — inspect gate is not-applicable. */
195
+ scriptMode: boolean;
196
+ listen: boolean;
197
+ /** Resolved JSON body (from `-f` / `-d` / `--input`). */
198
+ body: Record<string, string>;
199
+ /** REST `.query` words (no `?` prefix), structured then raw, in order. */
200
+ query: readonly string[];
201
+ proplist: readonly string[];
202
+ raw: boolean;
203
+ /** `--count`: stop a `--stream` after N frames. */
204
+ count?: number;
205
+ /** `--duration` parsed to ms: stop a `--stream` after this wall-clock window. */
206
+ durationMs?: number;
207
+ via: ResolvedSetting<RouterOsProtocol>;
208
+ target: ResolvedTarget;
209
+ auth: ResolvedAuth;
210
+ timeoutMs: ResolvedSetting<number>;
211
+ format: ResolvedSetting<ApiOutputFormat>;
212
+ validate: ResolvedSetting<boolean>;
213
+ insecure: ResolvedSetting<boolean>;
214
+ yes: boolean;
215
+ verbose: boolean;
216
+ warnings: readonly Warning[];
217
+ }
218
+
219
+ export async function api(
220
+ request: ApiRequest,
221
+ env: Record<string, string | undefined> = Bun.env,
222
+ ): Promise<ApiSuccessEnvelope> {
223
+ const envelope = await apiEnvelope(request, env);
224
+ if (!envelope.ok) {
225
+ throw new CentrsError({
226
+ code: envelope.error.code,
227
+ summary: envelope.error.summary,
228
+ remediation: envelope.error.remediation,
229
+ context: envelope.error.context,
230
+ causeData: envelope.error.cause,
231
+ });
232
+ }
233
+ return envelope;
234
+ }
235
+
236
+ export async function apiEnvelope(
237
+ request: ApiRequest,
238
+ env: Record<string, string | undefined> = Bun.env,
239
+ ): Promise<ApiEnvelope> {
240
+ let resolved: ResolvedApiRequest | undefined;
241
+ try {
242
+ resolved = await resolveApiRequest(request, env);
243
+ await assertApiWriteConfirmed(request, resolved);
244
+ return await runResolvedApi(resolved);
245
+ } catch (error) {
246
+ return resolved
247
+ ? buildApiErrorEnvelopeFromResolved(resolved, error)
248
+ : buildApiErrorEnvelope(request, error, env);
249
+ }
250
+ }
251
+
252
+ export async function runResolvedApi(
253
+ resolved: ResolvedApiRequest,
254
+ ): Promise<ApiSuccessEnvelope> {
255
+ assertListenCapability(resolved);
256
+ const backend = createProtocolAdapter({
257
+ protocol: resolved.via.value,
258
+ host: resolved.target.host,
259
+ port: resolved.target.port,
260
+ tls: resolved.target.tls,
261
+ baseUrl: resolved.target.baseUrl,
262
+ username: resolved.auth.username,
263
+ password: resolved.auth.password,
264
+ timeoutMs: resolved.timeoutMs.value,
265
+ mac: resolved.target.mac,
266
+ insecure: resolved.insecure.value,
267
+ });
268
+
269
+ let validation: EnvelopeValidationMeta;
270
+ let tips: Tip[] = [];
271
+ try {
272
+ if (resolved.validate.value) {
273
+ const result = await validateApiRequest(resolved, backend);
274
+ validation = result.validation;
275
+ tips = result.tips;
276
+ } else {
277
+ validation = {
278
+ enabled: false,
279
+ source: "disabled",
280
+ result: "skipped",
281
+ syntax: false,
282
+ semantic: false,
283
+ };
284
+ }
285
+
286
+ const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
287
+ return {
288
+ ok: true,
289
+ data: result.data,
290
+ warnings: [...resolved.warnings],
291
+ tips,
292
+ meta: metaFromResolved(resolved, validation, result.data),
293
+ };
294
+ } finally {
295
+ await backend.close();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The open-ended `--stream` follow path: an async generator yielding one
301
+ * {@link ApiEnvelope} per `/listen` change frame, then a terminating summary
302
+ * envelope (`data` = {@link ApiStreamSummary}). Open-ended follow is native-api
303
+ * only — a `rest-api` (or otherwise unresolved) transport yields a single error
304
+ * envelope. `--count`/`--duration` and `externalSignal` (Ctrl-C) bound it.
305
+ * Errors before the first frame (resolution, validation, confirmation,
306
+ * capability) yield one error envelope and stop — the CLI keys its exit code on
307
+ * that first envelope. `onListening` fires once the follow is established on the
308
+ * wire (a real barrier for callers that must act only after that point).
309
+ */
310
+ export async function* apiListen(
311
+ request: ApiRequest,
312
+ env: Record<string, string | undefined> = Bun.env,
313
+ externalSignal?: AbortSignal,
314
+ onListening?: () => void,
315
+ ): AsyncGenerator<ApiEnvelope, void, void> {
316
+ // `apiListen` is inherently the streaming surface, so force `listen` on the
317
+ // request: otherwise a caller that omits it (and the `/listen` endpoint form)
318
+ // would resolve `via` to the rest-api default and be rejected. An explicit
319
+ // `--via rest-api` still surfaces capability-unsupported below.
320
+ const streamRequest: ApiRequest = { ...request, listen: true };
321
+ let resolved: ResolvedApiRequest | undefined;
322
+ try {
323
+ resolved = await resolveApiRequest(streamRequest, env);
324
+ await assertApiWriteConfirmed(streamRequest, resolved);
325
+ if (resolved.via.value !== "native-api") {
326
+ throw new CentrsError({
327
+ code: "transport/capability-unsupported",
328
+ summary: "REST cannot follow an open-ended `--stream` (60s cap).",
329
+ remediation:
330
+ "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
331
+ context: { via: resolved.via.value, capability: "listen" },
332
+ });
333
+ }
334
+ } catch (error) {
335
+ yield resolved
336
+ ? buildApiErrorEnvelopeFromResolved(resolved, error)
337
+ : buildApiErrorEnvelope(streamRequest, error, env);
338
+ return;
339
+ }
340
+ yield* streamResolvedApi(resolved, externalSignal, onListening);
341
+ }
342
+
343
+ async function* streamResolvedApi(
344
+ resolved: ResolvedApiRequest,
345
+ externalSignal?: AbortSignal,
346
+ onListening?: () => void,
347
+ ): AsyncGenerator<ApiEnvelope, void, void> {
348
+ const backend = adapterForResolved(resolved);
349
+
350
+ let validation: EnvelopeValidationMeta;
351
+ try {
352
+ validation = resolved.validate.value
353
+ ? (await validateApiRequest(resolved, backend)).validation
354
+ : disabledValidationMeta();
355
+ } catch (error) {
356
+ await backend.close();
357
+ yield buildApiErrorEnvelopeFromResolved(resolved, error);
358
+ return;
359
+ }
360
+
361
+ const controller = new AbortController();
362
+ const startedAt = Date.now();
363
+ let frames = 0;
364
+ let stopReason: ApiStreamStopReason | undefined;
365
+ let durationTimer: ReturnType<typeof setTimeout> | undefined;
366
+ const onExternalAbort = (): void => {
367
+ stopReason ??= "interrupted";
368
+ controller.abort();
369
+ };
370
+ if (resolved.durationMs !== undefined) {
371
+ durationTimer = setTimeout(() => {
372
+ stopReason = "duration-elapsed";
373
+ controller.abort();
374
+ }, resolved.durationMs);
375
+ }
376
+ if (externalSignal) {
377
+ if (externalSignal.aborted) {
378
+ onExternalAbort();
379
+ } else {
380
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
381
+ }
382
+ }
383
+
384
+ try {
385
+ const protocolRequest = buildProtocolApiRequest(resolved);
386
+ for await (const record of backend.listen(protocolRequest, {
387
+ signal: controller.signal,
388
+ onListening,
389
+ })) {
390
+ frames += 1;
391
+ yield streamFrameEnvelope(resolved, validation, record, frames);
392
+ if (resolved.count !== undefined && frames >= resolved.count) {
393
+ stopReason = "count-reached";
394
+ break;
395
+ }
396
+ }
397
+ yield streamSummaryEnvelope(
398
+ resolved,
399
+ validation,
400
+ stopReason ?? "interrupted",
401
+ frames,
402
+ Date.now() - startedAt,
403
+ );
404
+ } catch (error) {
405
+ // A mid-stream failure is itself a frame, tagged `stream.kind="frame"` so
406
+ // consumers that key off `meta.operation.stream` still see it in the stream
407
+ // (not as a stray non-stream envelope); the summary then closes with
408
+ // `transport-error` (the CLI's exit code keys on whether it *started*
409
+ // cleanly).
410
+ yield streamErrorFrameEnvelope(resolved, error, frames + 1);
411
+ yield streamSummaryEnvelope(
412
+ resolved,
413
+ validation,
414
+ "transport-error",
415
+ frames,
416
+ Date.now() - startedAt,
417
+ );
418
+ } finally {
419
+ if (durationTimer !== undefined) {
420
+ clearTimeout(durationTimer);
421
+ }
422
+ externalSignal?.removeEventListener("abort", onExternalAbort);
423
+ await backend.close();
424
+ }
425
+ }
426
+
427
+ function streamFrameEnvelope(
428
+ resolved: ResolvedApiRequest,
429
+ validation: EnvelopeValidationMeta,
430
+ record: Record<string, unknown>,
431
+ index: number,
432
+ ): ApiSuccessEnvelope {
433
+ const meta = metaFromResolved(resolved, validation, record);
434
+ meta.operation.stream = { kind: "frame", index };
435
+ return { ok: true, data: record, warnings: [], tips: [], meta };
436
+ }
437
+
438
+ /**
439
+ * A mid-stream failure rendered as a stream frame: the error envelope carries
440
+ * `meta.operation.stream.kind="frame"` (index after the last good frame) so
441
+ * stream consumers can place it on the timeline rather than mistaking it for a
442
+ * non-stream envelope.
443
+ */
444
+ function streamErrorFrameEnvelope(
445
+ resolved: ResolvedApiRequest,
446
+ error: unknown,
447
+ index: number,
448
+ ): ApiErrorEnvelope {
449
+ const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
450
+ if (envelope.meta.operation) {
451
+ envelope.meta.operation.stream = { kind: "frame", index };
452
+ }
453
+ return envelope;
454
+ }
455
+
456
+ function streamSummaryEnvelope(
457
+ resolved: ResolvedApiRequest,
458
+ validation: EnvelopeValidationMeta,
459
+ stopReason: ApiStreamStopReason,
460
+ frames: number,
461
+ durationMs: number,
462
+ ): ApiSuccessEnvelope {
463
+ const summary: ApiStreamSummary = { stopReason, frames, durationMs };
464
+ const meta = metaFromResolved(resolved, validation, summary);
465
+ meta.operation.objectCount = frames;
466
+ meta.operation.stream = { kind: "summary", stopReason, frames, durationMs };
467
+ return {
468
+ ok: true,
469
+ data: summary,
470
+ warnings: [...resolved.warnings],
471
+ tips: [],
472
+ meta,
473
+ };
474
+ }
475
+
476
+ function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
477
+ return createProtocolAdapter({
478
+ protocol: resolved.via.value,
479
+ host: resolved.target.host,
480
+ port: resolved.target.port,
481
+ tls: resolved.target.tls,
482
+ baseUrl: resolved.target.baseUrl,
483
+ username: resolved.auth.username,
484
+ password: resolved.auth.password,
485
+ timeoutMs: resolved.timeoutMs.value,
486
+ mac: resolved.target.mac,
487
+ insecure: resolved.insecure.value,
488
+ });
489
+ }
490
+
491
+ function disabledValidationMeta(): EnvelopeValidationMeta {
492
+ return {
493
+ enabled: false,
494
+ source: "disabled",
495
+ result: "skipped",
496
+ syntax: false,
497
+ semantic: false,
498
+ };
499
+ }
500
+
501
+ export async function resolveApiRequest(
502
+ request: ApiRequest,
503
+ env: Record<string, string | undefined>,
504
+ override?: {
505
+ /**
506
+ * A pre-resolved CDB record, supplied by fan-out so the CDB is loaded ONCE
507
+ * (not per member) and a `--default`/literal member is never re-resolved
508
+ * through `resolveCdb`'s `__default__` synthetic-target fallback.
509
+ */
510
+ cdbResolution?: CdbResolution;
511
+ /** A pre-loaded `centrs.env` config tier, supplied by fan-out so the file
512
+ * is read ONCE (not per member). */
513
+ config?: Record<string, string | undefined>;
514
+ },
515
+ ): Promise<ResolvedApiRequest> {
516
+ validateApiRequestShape(request);
517
+ const config = override?.config ?? (await loadEnvFileDefaults(env));
518
+ const normalized = normalizeApiEndpoint(request.endpoint);
519
+ const method = parseApiMethod(request.method);
520
+ const verb = mapMethodToVerb(method);
521
+ const listen = (request.listen ?? false) || normalized.listen;
522
+ // `/execute` (root, single token) run via POST is the script surface — a CLI
523
+ // string, not a menu path. A GET, a nested path, or any other menu that merely
524
+ // ends in `execute` stays a normal path request validated through inspect.
525
+ const tokens = pathTokens(normalized.path);
526
+ const scriptMode =
527
+ method === "POST" && tokens.length === 1 && tokens[0] === "execute";
528
+ // PATCH/DELETE (set/remove) address one row; without an id the REST URL would
529
+ // carry a `/undefined` segment and native would send an empty `=.id=`. Reject
530
+ // it here with an actionable error instead of issuing a malformed request.
531
+ if ((verb === "set" || verb === "remove") && normalized.id === undefined) {
532
+ throw new CentrsError({
533
+ code: "input/invalid-path",
534
+ summary: `A ${method} request must address one row by id (e.g. ${normalized.path}/*1).`,
535
+ remediation:
536
+ "Append the row's `.id` to the endpoint, such as `ip/address/*1`; find ids with a GET of the collection.",
537
+ context: { method, path: normalized.path },
538
+ });
539
+ }
540
+ const raw = request.raw ?? false;
541
+ const body = buildApiBody(request);
542
+ const query = buildApiQuery(request);
543
+ const proplist = buildApiProplist(request);
544
+
545
+ const cdbResolution =
546
+ override?.cdbResolution ??
547
+ (await resolveCdb(
548
+ {
549
+ targetInput: request.targetInput,
550
+ cdbFile: request.cdbFile,
551
+ cdbPassword: request.cdbPassword,
552
+ },
553
+ env,
554
+ config,
555
+ ));
556
+ const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
557
+ const format = resolveApiFormat(request, env, config);
558
+ // `--raw` forces validation off (constitution: --raw waiver); otherwise the
559
+ // inspect gate is on by default.
560
+ const validate: ResolvedSetting<boolean> = raw
561
+ ? { value: false, source: { kind: "cli", key: "--raw" } }
562
+ : resolveBooleanSetting(
563
+ request.validate,
564
+ env,
565
+ "CENTRS_VALIDATE",
566
+ true,
567
+ "validate",
568
+ cdbResolution?.overrides.validate,
569
+ config,
570
+ );
571
+ const timeoutMs = resolveApiTimeout(
572
+ request.timeout,
573
+ env,
574
+ via.value,
575
+ cdbResolution?.overrides.timeoutMs,
576
+ config,
577
+ );
578
+ const macResolution = isIpTransport(via.value)
579
+ ? await resolveMacTarget({
580
+ host: request.host,
581
+ targetInput: request.targetInput,
582
+ cdbTarget: cdbResolution?.target,
583
+ env,
584
+ config,
585
+ policy: parseResolvePolicy(
586
+ request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
587
+ ),
588
+ // api has no Layer-2 transport (rest-api/native-api only), so MAC
589
+ // remediation matches retrieve's (no mac-telnet suggestion).
590
+ operation: "retrieve",
591
+ })
592
+ : undefined;
593
+ const target = resolveTarget(
594
+ {
595
+ targetInput: request.targetInput,
596
+ host: request.host,
597
+ port: request.port,
598
+ macResolution,
599
+ },
600
+ env,
601
+ via.value,
602
+ cdbResolution,
603
+ config,
604
+ );
605
+ const auth = resolveAuth(
606
+ { username: request.username, password: request.password },
607
+ env,
608
+ cdbResolution,
609
+ config,
610
+ );
611
+ const insecure = resolveBooleanSetting(
612
+ request.insecure,
613
+ env,
614
+ "CENTRS_INSECURE",
615
+ false,
616
+ "insecure",
617
+ cdbResolution?.overrides.insecure,
618
+ config,
619
+ );
620
+
621
+ return {
622
+ endpoint: request.endpoint,
623
+ path: normalized.path,
624
+ id: normalized.id,
625
+ method,
626
+ verb,
627
+ scriptMode,
628
+ listen,
629
+ body,
630
+ query,
631
+ proplist,
632
+ raw,
633
+ count: request.count,
634
+ durationMs:
635
+ request.duration !== undefined
636
+ ? parseDuration(request.duration)
637
+ : undefined,
638
+ via,
639
+ target,
640
+ auth,
641
+ timeoutMs,
642
+ format,
643
+ validate,
644
+ insecure,
645
+ yes: request.yes ?? false,
646
+ verbose: request.verbose ?? false,
647
+ warnings: cdbResolution?.warnings ?? [],
648
+ };
649
+ }
650
+
651
+ /**
652
+ * Leniently canonicalize an endpoint. Drops a leading `/rest`, splits on slashes
653
+ * **and** whitespace (so `"ip address"` works), strips a trailing `*id` and a
654
+ * trailing `listen` segment. See `commands/api/README.md`.
655
+ */
656
+ export function normalizeApiEndpoint(endpoint: string): NormalizedApiEndpoint {
657
+ const segments = endpoint
658
+ .trim()
659
+ .split(/[/\s]+/)
660
+ .filter(Boolean);
661
+ if (segments[0]?.toLowerCase() === "rest") {
662
+ segments.shift();
663
+ }
664
+ let listen = false;
665
+ if (segments.at(-1)?.toLowerCase() === "listen") {
666
+ listen = true;
667
+ segments.pop();
668
+ }
669
+ let id: string | undefined;
670
+ const last = segments.at(-1);
671
+ if (last !== undefined && /^\*[0-9A-F]+$/i.test(last)) {
672
+ id = last;
673
+ segments.pop();
674
+ }
675
+ return { path: `/${segments.join("/")}`, id, listen };
676
+ }
677
+
678
+ /** The gh-api `-X` map. `print` covers list / get-singleton / get-one (by id). */
679
+ export function mapMethodToVerb(method: ApiMethod): ApiVerb {
680
+ switch (method) {
681
+ case "GET":
682
+ return "print";
683
+ case "PUT":
684
+ return "add";
685
+ case "PATCH":
686
+ return "set";
687
+ case "DELETE":
688
+ return "remove";
689
+ case "POST":
690
+ return "run";
691
+ default:
692
+ return exhaustiveMethod(method);
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Read-only **iff** the method is `GET`, or the terminal path verb is
698
+ * `print`/`get`/`listen`. Everything else is a write needing confirmation. Keyed
699
+ * on the verb/method, not the wire HTTP method — a `POST …/print` paged read does
700
+ * not prompt; `--listen` never relaxes the gate (a streaming-but-mutating
701
+ * `/system/license/renew` still confirms). See `commands/api/README.md`.
702
+ */
703
+ export function isApiMutating(method: ApiMethod, path: string): boolean {
704
+ if (method === "GET") {
705
+ return false;
706
+ }
707
+ const terminal = pathTokens(path).at(-1);
708
+ if (terminal === "print" || terminal === "get" || terminal === "listen") {
709
+ return false;
710
+ }
711
+ return true;
712
+ }
713
+
714
+ /** Merge `-f` / `-d` / `--input` into one JSON body; reject combining them. */
715
+ export function buildApiBody(request: ApiRequest): Record<string, string> {
716
+ const hasFields =
717
+ request.fields !== undefined && Object.keys(request.fields).length > 0;
718
+ const hasData = request.data !== undefined;
719
+ const hasInput = request.inputBody !== undefined;
720
+ const sources = [hasFields, hasData, hasInput].filter(Boolean).length;
721
+ if (sources > 1) {
722
+ throw new CentrsError({
723
+ code: "usage/conflicting-flags",
724
+ summary:
725
+ "`api` accepts only one body source: `-f` fields, `-d <json>`, or `--input`.",
726
+ remediation:
727
+ "Pass body fields with repeated `-f key=value`, or one raw JSON body via `-d` or `--input` — not a mix.",
728
+ });
729
+ }
730
+ if (hasData) {
731
+ return parseJsonBody(request.data ?? "", "-d");
732
+ }
733
+ if (hasInput) {
734
+ return parseJsonBody(request.inputBody ?? "", "--input");
735
+ }
736
+ return { ...(request.fields ?? {}) };
737
+ }
738
+
739
+ function parseJsonBody(raw: string, flag: string): Record<string, string> {
740
+ let parsed: unknown;
741
+ try {
742
+ parsed = JSON.parse(raw);
743
+ } catch (error) {
744
+ throw new CentrsError({
745
+ code: "usage/conflicting-flags",
746
+ summary: `The ${flag} body is not valid JSON.`,
747
+ remediation:
748
+ 'Pass a JSON object such as `{"address":"198.51.100.1/32"}`.',
749
+ cause: error,
750
+ });
751
+ }
752
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
753
+ throw new CentrsError({
754
+ code: "usage/conflicting-flags",
755
+ summary: `The ${flag} body must be a JSON object.`,
756
+ remediation: "Wrap the fields in a single `{ ... }` object.",
757
+ });
758
+ }
759
+ const body: Record<string, string> = {};
760
+ for (const [key, value] of Object.entries(parsed)) {
761
+ body[key] = typeof value === "string" ? value : JSON.stringify(value);
762
+ }
763
+ return body;
764
+ }
765
+
766
+ /**
767
+ * Build REST `.query` words (no `?` prefix). Structured `--query` words first, in
768
+ * order, then verbatim `--raw-query` words. Mapping is CHR-grounded
769
+ * (`commands/api/AGENTS.md`): `name=value` (eq), `name!=value` → `name=value`,
770
+ * `#!` (eq then NOT-top), `name>value`/`name<value` → `>name=value`/`<name=value`,
771
+ * bare `name` (has-property).
772
+ */
773
+ export function buildApiQuery(request: ApiRequest): string[] {
774
+ const words: string[] = [];
775
+ for (const expression of request.query ?? []) {
776
+ words.push(...queryExpressionToWords(expression));
777
+ }
778
+ for (const raw of request.rawQuery ?? []) {
779
+ words.push(raw);
780
+ }
781
+ return words;
782
+ }
783
+
784
+ function queryExpressionToWords(expression: string): string[] {
785
+ const ne = expression.indexOf("!=");
786
+ if (ne >= 0) {
787
+ return [`${expression.slice(0, ne)}=${expression.slice(ne + 2)}`, "#!"];
788
+ }
789
+ const gt = expression.indexOf(">");
790
+ if (gt >= 0) {
791
+ return [`>${expression.slice(0, gt)}=${expression.slice(gt + 1)}`];
792
+ }
793
+ const lt = expression.indexOf("<");
794
+ if (lt >= 0) {
795
+ return [`<${expression.slice(0, lt)}=${expression.slice(lt + 1)}`];
796
+ }
797
+ // `name=value` (eq) or bare `name` (has-property) pass through verbatim.
798
+ return [expression];
799
+ }
800
+
801
+ function buildApiProplist(request: ApiRequest): string[] {
802
+ const props: string[] = [];
803
+ for (const entry of request.proplist ?? []) {
804
+ for (const part of entry.split(",")) {
805
+ const trimmed = part.trim();
806
+ if (trimmed.length > 0) {
807
+ props.push(trimmed);
808
+ }
809
+ }
810
+ }
811
+ return props;
812
+ }
813
+
814
+ export function buildProtocolApiRequest(
815
+ resolved: ResolvedApiRequest,
816
+ ): ProtocolApiRequest {
817
+ const request: ProtocolApiRequest = {
818
+ verb: resolved.verb,
819
+ path: resolved.path,
820
+ };
821
+ if (resolved.id) {
822
+ request.id = resolved.id;
823
+ }
824
+ if (resolved.scriptMode) {
825
+ const { script, ...extra } = resolved.body;
826
+ if (typeof script !== "string" || script.trim().length === 0) {
827
+ throw new CentrsError({
828
+ code: "input/invalid-command",
829
+ summary: "`/execute` requires a non-empty `script` field.",
830
+ remediation:
831
+ "Pass the script with `-f script='...'`, `-d '{\"script\":\"...\"}'`, or `--input`.",
832
+ });
833
+ }
834
+ if (Object.keys(extra).length > 0) {
835
+ throw new CentrsError({
836
+ code: "usage/conflicting-flags",
837
+ summary: "`/execute` accepts only the `script` body field.",
838
+ remediation:
839
+ "Remove the extra fields, or target a concrete RouterOS command path instead of `/execute`.",
840
+ context: { extraFields: Object.keys(extra) },
841
+ });
842
+ }
843
+ request.script = script;
844
+ return request;
845
+ }
846
+ if (
847
+ resolved.verb === "add" ||
848
+ resolved.verb === "set" ||
849
+ resolved.verb === "run"
850
+ ) {
851
+ if (Object.keys(resolved.body).length > 0) {
852
+ request.attributes = resolved.body;
853
+ }
854
+ }
855
+ if (resolved.verb === "print") {
856
+ if (resolved.query.length > 0) {
857
+ request.query = resolved.query;
858
+ }
859
+ if (resolved.proplist.length > 0) {
860
+ request.proplist = resolved.proplist;
861
+ }
862
+ }
863
+ return request;
864
+ }
865
+
866
+ interface ApiValidationResult {
867
+ validation: EnvelopeValidationMeta;
868
+ tips: Tip[];
869
+ }
870
+
871
+ /**
872
+ * The structured-input gate. Because the input is a path (not a CLI string) the
873
+ * validator is `/console/inspect`, never `:put [:parse]`. Path existence via
874
+ * `request=child`; add/set attribute validity via `request=child`+`completion`. A
875
+ * `/execute` script is a CLI string → `semantic: not-applicable`.
876
+ */
877
+ async function validateApiRequest(
878
+ resolved: ResolvedApiRequest,
879
+ backend: ProtocolAdapter,
880
+ ): Promise<ApiValidationResult> {
881
+ if (resolved.scriptMode) {
882
+ return {
883
+ validation: {
884
+ enabled: true,
885
+ source: "/console/inspect (script, not-applicable)",
886
+ result: "passed",
887
+ syntax: false,
888
+ semantic: "not-applicable",
889
+ },
890
+ tips: [],
891
+ };
892
+ }
893
+
894
+ const tokens = pathTokens(resolved.path);
895
+ const tips: Tip[] = [];
896
+
897
+ if (resolved.verb === "run") {
898
+ // The path already carries the command verb (`/interface/monitor-traffic`);
899
+ // verify it exists as a child of its parent menu.
900
+ const parent = tokens.slice(0, -1);
901
+ const terminal = tokens.at(-1) ?? "";
902
+ const parentChildren = await inspectChildrenOrEmpty(backend, parent);
903
+ if (!parentChildren.some((child) => child.name === terminal)) {
904
+ throw unknownPathError(resolved);
905
+ }
906
+ const isCommand = parentChildren.some((child) =>
907
+ isCommandNode(child, terminal),
908
+ );
909
+ // A bare-collection POST carrying fields (terminal is a menu, not a command)
910
+ // likely meant PUT (RouterOS create). Advise, never rewrite the method.
911
+ if (
912
+ resolved.method === "POST" &&
913
+ !isCommand &&
914
+ resolved.id === undefined &&
915
+ Object.keys(resolved.body).length > 0
916
+ ) {
917
+ tips.push(
918
+ buildTip(
919
+ "tip/rest-verb-mapping",
920
+ `POST ${resolved.path} carries fields but its terminal segment is a menu, not a command.`,
921
+ "RouterOS creates with PUT (`-X PUT`), not POST. Use PUT to add a row; centrs never rewrites your method.",
922
+ ),
923
+ );
924
+ }
925
+ // When the terminal is a real command carrying arguments, validate them the
926
+ // same way add/set do — so a mistyped command argument is caught preflight.
927
+ // Only reject when inspect actually surfaced the command's arguments; if it
928
+ // returns none, skip rather than false-reject a valid command.
929
+ if (isCommand && Object.keys(resolved.body).length > 0) {
930
+ const available = await inspectApiAttributes(backend, tokens);
931
+ if (available.length > 0) {
932
+ const requested = Object.keys(resolved.body);
933
+ const missing = requested.filter(
934
+ (attribute) => attribute !== ".id" && !available.includes(attribute),
935
+ );
936
+ if (missing.length > 0) {
937
+ throw new CentrsError({
938
+ code: "validation/unknown-attribute",
939
+ summary: `Unknown RouterOS argument ${missing.join(", ")} for ${resolved.path}.`,
940
+ remediation:
941
+ "Check the command arguments against `/console/inspect`, or use `--validate=false` only when intentionally probing an undocumented RouterOS edge.",
942
+ context: {
943
+ path: resolved.path,
944
+ verb: resolved.verb,
945
+ attribute: missing[0],
946
+ requestedAttributes: requested,
947
+ availableAttributes: available,
948
+ validationSource: "/console/inspect request=child+completion",
949
+ },
950
+ });
951
+ }
952
+ }
953
+ }
954
+ return {
955
+ validation: {
956
+ enabled: true,
957
+ source: "/console/inspect request=child",
958
+ result: "passed",
959
+ syntax: false,
960
+ semantic: true,
961
+ },
962
+ tips,
963
+ };
964
+ }
965
+
966
+ // print / add / set / remove: the path is the menu itself.
967
+ const children = await inspectChildrenOrEmpty(backend, tokens);
968
+ if (children.length === 0) {
969
+ throw unknownPathError(resolved);
970
+ }
971
+
972
+ if (resolved.verb === "add" || resolved.verb === "set") {
973
+ const available = await inspectApiAttributes(backend, [
974
+ ...tokens,
975
+ resolved.verb,
976
+ ]);
977
+ const requested = Object.keys(resolved.body);
978
+ const missing = requested.filter(
979
+ (attribute) => attribute !== ".id" && !available.includes(attribute),
980
+ );
981
+ if (missing.length > 0) {
982
+ throw new CentrsError({
983
+ code: "validation/unknown-attribute",
984
+ summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}/${resolved.verb}.`,
985
+ remediation:
986
+ "Check the attribute name against `/console/inspect`, or use `--validate=false` only when intentionally probing an undocumented RouterOS edge.",
987
+ context: {
988
+ path: resolved.path,
989
+ verb: resolved.verb,
990
+ attribute: missing[0],
991
+ requestedAttributes: requested,
992
+ availableAttributes: available,
993
+ validationSource: "/console/inspect request=child+completion",
994
+ },
995
+ });
996
+ }
997
+ return {
998
+ validation: {
999
+ enabled: true,
1000
+ source: "/console/inspect request=child+completion",
1001
+ result: "passed",
1002
+ syntax: false,
1003
+ semantic: true,
1004
+ availableAttributes: available,
1005
+ },
1006
+ tips,
1007
+ };
1008
+ }
1009
+
1010
+ return {
1011
+ validation: {
1012
+ enabled: true,
1013
+ source: "/console/inspect request=child",
1014
+ result: "passed",
1015
+ syntax: false,
1016
+ semantic: true,
1017
+ },
1018
+ tips,
1019
+ };
1020
+ }
1021
+
1022
+ function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
1023
+ return new CentrsError({
1024
+ code: "validation/unknown-path",
1025
+ summary: `RouterOS has no menu at ${resolved.path}.`,
1026
+ remediation:
1027
+ "Check the endpoint path against `/console/inspect`, or use `--validate=false` to bypass the preflight (RouterOS still validates the request).",
1028
+ context: {
1029
+ path: resolved.path,
1030
+ endpoint: resolved.endpoint,
1031
+ validationSource: "/console/inspect request=child",
1032
+ },
1033
+ });
1034
+ }
1035
+
1036
+ async function inspectApiAttributes(
1037
+ backend: ProtocolAdapter,
1038
+ commandTokens: readonly string[],
1039
+ ): Promise<string[]> {
1040
+ const children = await inspectChildren(backend, commandTokens);
1041
+ const childAttributes = children
1042
+ .filter(isArgumentNode)
1043
+ .map((child) => child.name)
1044
+ .filter(
1045
+ (name): name is string => typeof name === "string" && name.length > 0,
1046
+ );
1047
+ const completionRows = await inspectCompletions(backend, commandTokens);
1048
+ return [
1049
+ ...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
1050
+ ].sort();
1051
+ }
1052
+
1053
+ async function assertApiWriteConfirmed(
1054
+ request: ApiRequest,
1055
+ resolved: ResolvedApiRequest,
1056
+ ): Promise<void> {
1057
+ if (!isApiMutating(resolved.method, resolved.path) || resolved.yes) {
1058
+ return;
1059
+ }
1060
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
1061
+ const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
1062
+ `Run mutating ${resolved.method} ${resolved.path} over ${resolved.via.value}? Type yes to continue: `,
1063
+ );
1064
+ if (confirmed) {
1065
+ return;
1066
+ }
1067
+ }
1068
+ throw new CentrsError({
1069
+ code: "usage/confirmation-required",
1070
+ summary: `A mutating ${resolved.method} ${resolved.path} request requires explicit confirmation.`,
1071
+ remediation:
1072
+ "Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the request.",
1073
+ context: {
1074
+ endpoint: resolved.endpoint,
1075
+ path: resolved.path,
1076
+ method: resolved.method,
1077
+ via: resolved.via.value,
1078
+ },
1079
+ });
1080
+ }
1081
+
1082
+ function assertListenCapability(resolved: ResolvedApiRequest): void {
1083
+ if (!resolved.listen) {
1084
+ return;
1085
+ }
1086
+ if (resolved.via.value === "rest-api") {
1087
+ throw new CentrsError({
1088
+ code: "transport/capability-unsupported",
1089
+ summary: "REST cannot follow an open-ended `--stream` (60s cap).",
1090
+ remediation:
1091
+ "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
1092
+ context: { via: resolved.via.value, capability: "listen" },
1093
+ });
1094
+ }
1095
+ throw new CentrsError({
1096
+ code: "input/invalid-command",
1097
+ summary: "`--stream` is an open-ended follow, not a one-shot `api` call.",
1098
+ remediation:
1099
+ "Consume the stream via `apiListen()` (library) or `centrs api … --stream` (CLI); both yield an NDJSON envelope per change frame plus a final summary.",
1100
+ context: { via: resolved.via.value, capability: "listen" },
1101
+ });
1102
+ }
1103
+
1104
+ function resolveApiProtocol(
1105
+ request: ApiRequest,
1106
+ env: Record<string, string | undefined>,
1107
+ listen: boolean,
1108
+ cdb?: CdbResolution,
1109
+ config: Record<string, string | undefined> = {},
1110
+ ): ResolvedSetting<RouterOsProtocol> {
1111
+ // A `/listen` endpoint (or `--listen`) infers native-api when `--via` is unset;
1112
+ // REST stays the default for one-shot calls.
1113
+ const defaultVia: RouterOsProtocol = listen ? "native-api" : "rest-api";
1114
+ const via = resolveStringSetting(
1115
+ request.via,
1116
+ env,
1117
+ "CENTRS_VIA",
1118
+ defaultVia,
1119
+ "via",
1120
+ undefined,
1121
+ cdb?.overrides.via,
1122
+ config,
1123
+ );
1124
+ if (!via) {
1125
+ throw new CentrsError({
1126
+ code: "internal/unhandled",
1127
+ summary: "Failed to resolve the api protocol.",
1128
+ remediation: "Report this bug; api should default to rest-api.",
1129
+ });
1130
+ }
1131
+ assertApiProtocolSupported(via.value);
1132
+ return via as ResolvedSetting<RouterOsProtocol>;
1133
+ }
1134
+
1135
+ /**
1136
+ * Reject a protocol identifier `api` cannot run over. Throws `settings/invalid-via`
1137
+ * for an unknown protocol and `routeros/protocol-not-implemented` for a known
1138
+ * transport (e.g. ssh / mac-telnet) that `api` does not support. Shared so a
1139
+ * globally-pinned `--via` / `CENTRS_VIA` can be rejected as an orchestration
1140
+ * pre-flight error in fan-out (`api-fanout.ts`) rather than degrading into N
1141
+ * identical per-target resolve failures.
1142
+ */
1143
+ export function assertApiProtocolSupported(via: string): void {
1144
+ if (!plannedProtocols.includes(via as RouterOsProtocol)) {
1145
+ throw new CentrsError({
1146
+ code: "settings/invalid-via",
1147
+ summary: `Unsupported protocol identifier: ${via}`,
1148
+ remediation: "`api` runs over `rest-api` (default) or `native-api`.",
1149
+ context: { via },
1150
+ });
1151
+ }
1152
+ if (via !== "rest-api" && via !== "native-api") {
1153
+ throw new CentrsError({
1154
+ code: "routeros/protocol-not-implemented",
1155
+ summary: `api over ${via} is not supported.`,
1156
+ remediation:
1157
+ "Use `--via rest-api` (default) or `--via native-api`. Console transports (ssh/mac-telnet) run through `execute`/`terminal`.",
1158
+ context: { via, capability: "execute" },
1159
+ });
1160
+ }
1161
+ }
1162
+
1163
+ function resolveApiFormat(
1164
+ request: ApiRequest,
1165
+ env: Record<string, string | undefined>,
1166
+ config: Record<string, string | undefined> = {},
1167
+ ): ResolvedSetting<ApiOutputFormat> {
1168
+ return resolveStringSetting(
1169
+ request.format,
1170
+ env,
1171
+ "CENTRS_FORMAT",
1172
+ "json",
1173
+ "format",
1174
+ parseApiOutputFormat,
1175
+ undefined,
1176
+ config,
1177
+ ) as ResolvedSetting<ApiOutputFormat>;
1178
+ }
1179
+
1180
+ function resolveApiTimeout(
1181
+ timeout: ApiRequest["timeout"],
1182
+ env: Record<string, string | undefined>,
1183
+ via: RouterOsProtocol,
1184
+ commentKv?: ResolvedSetting<number>,
1185
+ config: Record<string, string | undefined> = {},
1186
+ ): ResolvedSetting<number> {
1187
+ const resolved = resolveStringSetting(
1188
+ timeout === undefined ? undefined : String(timeout),
1189
+ env,
1190
+ "CENTRS_TIMEOUT",
1191
+ "10000",
1192
+ "timeout",
1193
+ (value) => {
1194
+ const parsed = parseDuration(value);
1195
+ if (parsed <= 0) {
1196
+ throw new CentrsError({
1197
+ code: "settings/invalid-timeout",
1198
+ summary: `Timeout must be greater than zero. Received: ${value}`,
1199
+ remediation:
1200
+ "Use a positive integer in milliseconds or a suffix like `5s` / `500ms`.",
1201
+ });
1202
+ }
1203
+ return parsed;
1204
+ },
1205
+ commentKv,
1206
+ config,
1207
+ );
1208
+ if (!resolved) {
1209
+ throw new Error("timeout resolution produced no value");
1210
+ }
1211
+ if (via === "rest-api" && resolved.value > 60_000) {
1212
+ throw new CentrsError({
1213
+ code: "usage/timeout-out-of-range",
1214
+ summary: `REST timeout ${resolved.value}ms exceeds the RouterOS REST ceiling.`,
1215
+ remediation: "Use `--timeout 60s` or less for the REST api path.",
1216
+ context: { via, timeoutMs: resolved.value, ceilingMs: 60_000 },
1217
+ });
1218
+ }
1219
+ return resolved;
1220
+ }
1221
+
1222
+ export function buildApiErrorEnvelope(
1223
+ request: ApiRequest,
1224
+ error: unknown,
1225
+ env: Record<string, string | undefined> = Bun.env,
1226
+ ): ApiErrorEnvelope {
1227
+ const centrsError = asApiError(error);
1228
+ const requestedVia = plannedProtocols.includes(
1229
+ request.via as RouterOsProtocol,
1230
+ )
1231
+ ? (request.via as RouterOsProtocol)
1232
+ : null;
1233
+ // Preserve the caller's raw method on the error path rather than fabricating a
1234
+ // valid GET/print: an invalid `-X` should report what was actually asked.
1235
+ const parsedMethod = tryParseApiMethod(request.method);
1236
+ const reportedPath = safeNormalizePath(request.endpoint);
1237
+ return {
1238
+ ok: false,
1239
+ error: serializeCentrsError(centrsError),
1240
+ warnings: [],
1241
+ tips: [],
1242
+ meta: {
1243
+ target: { input: request.targetInput },
1244
+ via: requestedVia,
1245
+ settings: {},
1246
+ operation: {
1247
+ kind: "api",
1248
+ objectCount: 0,
1249
+ request: {
1250
+ endpoint: request.endpoint,
1251
+ path: reportedPath,
1252
+ method: parsedMethod ?? request.method ?? "GET",
1253
+ verb: parsedMethod ? mapMethodToVerb(parsedMethod) : null,
1254
+ write: parsedMethod
1255
+ ? isApiMutating(parsedMethod, reportedPath)
1256
+ : false,
1257
+ listen: request.listen ?? false,
1258
+ yes: request.yes ?? false,
1259
+ validate: request.raw ? false : (request.validate ?? true),
1260
+ raw: request.raw ?? false,
1261
+ format: resolveErrorFormat(request, env),
1262
+ },
1263
+ auth: {
1264
+ username: request.username,
1265
+ passwordProvided: request.password !== undefined,
1266
+ },
1267
+ },
1268
+ },
1269
+ };
1270
+ }
1271
+
1272
+ export function buildApiErrorEnvelopeFromResolved(
1273
+ resolved: ResolvedApiRequest,
1274
+ error: unknown,
1275
+ ): ApiErrorEnvelope {
1276
+ const centrsError = asApiError(error);
1277
+ return {
1278
+ ok: false,
1279
+ error: serializeCentrsError(centrsError),
1280
+ warnings: [...resolved.warnings],
1281
+ tips: [],
1282
+ meta: metaFromResolved(resolved, {
1283
+ enabled: resolved.validate.value,
1284
+ source: resolved.validate.value ? "/console/inspect" : "disabled",
1285
+ result: resolved.validate.value ? "failed" : "skipped",
1286
+ syntax: false,
1287
+ semantic: resolved.validate.value
1288
+ ? resolved.scriptMode
1289
+ ? "not-applicable"
1290
+ : false
1291
+ : false,
1292
+ }),
1293
+ };
1294
+ }
1295
+
1296
+ function asApiError(error: unknown): CentrsError {
1297
+ return error instanceof CentrsError
1298
+ ? error
1299
+ : new CentrsError({
1300
+ code: "internal/unhandled",
1301
+ summary: "api failed with an unexpected internal error.",
1302
+ remediation:
1303
+ "Re-run with `--format json` to capture the structured error details for debugging.",
1304
+ cause: error,
1305
+ });
1306
+ }
1307
+
1308
+ export function renderApiEnvelope(
1309
+ envelope: ApiEnvelope,
1310
+ format: ApiOutputFormat,
1311
+ options: { raw?: boolean; verbose?: boolean } = {},
1312
+ ): string {
1313
+ if (options.raw) {
1314
+ // `--raw` waives the envelope-lossless rule: success is the bare RouterOS
1315
+ // body; an error is a compact RouterOS/centrs error payload (the CLI routes
1316
+ // it to stderr with a nonzero exit).
1317
+ return envelope.ok
1318
+ ? JSON.stringify(envelope.data, null, 2)
1319
+ : JSON.stringify(rawErrorPayload(envelope.error), null, 2);
1320
+ }
1321
+ switch (format) {
1322
+ case "json":
1323
+ return JSON.stringify(envelope, null, 2);
1324
+ case "yaml":
1325
+ return toYaml(envelope);
1326
+ case "text":
1327
+ return envelope.ok
1328
+ ? renderApiSuccessText(envelope, options)
1329
+ : renderApiErrorText(envelope, options);
1330
+ default:
1331
+ return exhaustiveFormat(format);
1332
+ }
1333
+ }
1334
+
1335
+ /**
1336
+ * Render one `--stream` envelope as a **single line**: compact NDJSON (one JSON
1337
+ * object per line) for both `json` and `yaml`, and a concise human row for
1338
+ * `text`. `yaml` deliberately falls back to NDJSON here — YAML's multi-line
1339
+ * document form is incompatible with line-delimited streaming. Unlike
1340
+ * {@link renderApiEnvelope} this never pretty-prints — every frame and the
1341
+ * summary are one line each, so a consumer can read the stream line by line.
1342
+ */
1343
+ export function renderApiStreamLine(
1344
+ envelope: ApiEnvelope,
1345
+ format: ApiOutputFormat,
1346
+ options: { raw?: boolean; verbose?: boolean } = {},
1347
+ ): string {
1348
+ if (options.raw) {
1349
+ return envelope.ok
1350
+ ? JSON.stringify(envelope.data)
1351
+ : JSON.stringify(rawErrorPayload(envelope.error));
1352
+ }
1353
+ if (format === "text") {
1354
+ if (envelope.ok) {
1355
+ return renderApiStreamFrameText(envelope);
1356
+ }
1357
+ // A streamed error must stay one line too — never the verbose blank-line +
1358
+ // pretty-printed context block that `renderApiErrorText` emits.
1359
+ const error = envelope.error;
1360
+ return error.remediation
1361
+ ? `[${error.code}] ${error.summary} — Fix: ${error.remediation}`
1362
+ : `[${error.code}] ${error.summary}`;
1363
+ }
1364
+ // json and yaml both stream as NDJSON: one compact envelope object per line.
1365
+ // (yaml falls back to JSON — a multi-line YAML doc can't be one stream line.)
1366
+ return JSON.stringify(envelope);
1367
+ }
1368
+
1369
+ function renderApiStreamFrameText(envelope: ApiSuccessEnvelope): string {
1370
+ const stream = envelope.meta.operation?.stream;
1371
+ if (stream?.kind === "summary") {
1372
+ return `— ${stream.stopReason}: ${stream.frames} frame(s) in ${stream.durationMs}ms`;
1373
+ }
1374
+ const index = stream?.kind === "frame" ? stream.index : 0;
1375
+ return `${index}\t${JSON.stringify(envelope.data)}`;
1376
+ }
1377
+
1378
+ function rawErrorPayload(
1379
+ error: ApiErrorEnvelope["error"],
1380
+ ): Record<string, unknown> {
1381
+ const payload: Record<string, unknown> = {
1382
+ code: error.code,
1383
+ message: error.summary,
1384
+ };
1385
+ if (error.cause !== undefined) {
1386
+ payload["cause"] = error.cause;
1387
+ }
1388
+ if (error.context !== undefined) {
1389
+ payload["context"] = error.context;
1390
+ }
1391
+ return payload;
1392
+ }
1393
+
1394
+ function renderApiSuccessText(
1395
+ envelope: ApiSuccessEnvelope,
1396
+ options: { verbose?: boolean },
1397
+ ): string {
1398
+ const lines: string[] = [];
1399
+ if (options.verbose) {
1400
+ lines.push(`via: ${envelope.meta.via}`);
1401
+ if (envelope.meta.validation?.source) {
1402
+ lines.push(`validation: ${envelope.meta.validation.source}`);
1403
+ }
1404
+ lines.push("");
1405
+ }
1406
+ lines.push(
1407
+ typeof envelope.data === "string"
1408
+ ? envelope.data
1409
+ : JSON.stringify(envelope.data, null, 2),
1410
+ );
1411
+ return lines.join("\n");
1412
+ }
1413
+
1414
+ function renderApiErrorText(
1415
+ envelope: ApiErrorEnvelope,
1416
+ options: { verbose?: boolean },
1417
+ ): string {
1418
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
1419
+ if (envelope.error.remediation) {
1420
+ lines.push(`Fix: ${envelope.error.remediation}`);
1421
+ }
1422
+ if (options.verbose && envelope.error.context) {
1423
+ lines.push("", JSON.stringify(envelope.error.context, null, 2));
1424
+ }
1425
+ return lines.join("\n");
1426
+ }
1427
+
1428
+ export function validateApiRequestShape(request: ApiRequest): void {
1429
+ if (!request.endpoint || request.endpoint.trim().length === 0) {
1430
+ throw new CentrsError({
1431
+ code: "input/invalid-command",
1432
+ summary: "`centrs api` requires an <endpoint> path.",
1433
+ remediation:
1434
+ "Pass a REST-style endpoint such as `ip/address` or `system/resource`.",
1435
+ });
1436
+ }
1437
+ const normalized = normalizeApiEndpoint(request.endpoint);
1438
+ if (pathTokens(normalized.path).length === 0) {
1439
+ throw new CentrsError({
1440
+ code: "input/invalid-path",
1441
+ summary: `The endpoint "${request.endpoint}" has no RouterOS path.`,
1442
+ remediation:
1443
+ "Pass a path with at least one menu segment, such as `ip/address`.",
1444
+ });
1445
+ }
1446
+ }
1447
+
1448
+ export function parseApiMethod(method: string | undefined): ApiMethod {
1449
+ if (method === undefined) {
1450
+ return "GET";
1451
+ }
1452
+ const upper = method.toUpperCase();
1453
+ if ((apiMethods as readonly string[]).includes(upper)) {
1454
+ return upper as ApiMethod;
1455
+ }
1456
+ throw new CentrsError({
1457
+ code: "usage/invalid-method",
1458
+ summary: `Unsupported HTTP method: ${method}`,
1459
+ remediation: `Choose one of ${apiMethods.join(", ")}.`,
1460
+ context: { method },
1461
+ });
1462
+ }
1463
+
1464
+ /**
1465
+ * Build the request-level summary (no target) for the fan-out outer meta and
1466
+ * for an empty selection where no per-target inner envelope exists. Mirrors the
1467
+ * summary `buildApiErrorEnvelope` assembles from a bare request.
1468
+ */
1469
+ export function apiRequestSummaryFromRequest(
1470
+ request: ApiRequest,
1471
+ env: Record<string, string | undefined> = Bun.env,
1472
+ ): ApiRequestSummary {
1473
+ const normalized = normalizeApiEndpoint(request.endpoint);
1474
+ const parsed = tryParseApiMethod(request.method);
1475
+ const listen = (request.listen ?? false) || normalized.listen;
1476
+ return {
1477
+ endpoint: request.endpoint,
1478
+ path: normalized.path,
1479
+ id: normalized.id,
1480
+ method: parsed ?? request.method ?? "GET",
1481
+ verb: parsed ? mapMethodToVerb(parsed) : null,
1482
+ write: parsed ? isApiMutating(parsed, normalized.path) : false,
1483
+ listen,
1484
+ yes: request.yes ?? false,
1485
+ validate: request.raw ? false : (request.validate ?? true),
1486
+ raw: request.raw ?? false,
1487
+ format: resolveErrorFormat(request, env),
1488
+ query: buildApiQuery(request),
1489
+ proplist: buildApiProplist(request),
1490
+ };
1491
+ }
1492
+
1493
+ /** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
1494
+ function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
1495
+ if (method === undefined) {
1496
+ return "GET";
1497
+ }
1498
+ const upper = method.toUpperCase();
1499
+ return (apiMethods as readonly string[]).includes(upper)
1500
+ ? (upper as ApiMethod)
1501
+ : undefined;
1502
+ }
1503
+
1504
+ function safeNormalizePath(endpoint: string): string {
1505
+ try {
1506
+ return normalizeApiEndpoint(endpoint).path;
1507
+ } catch {
1508
+ return endpoint;
1509
+ }
1510
+ }
1511
+
1512
+ function parseApiOutputFormat(value: string): ApiOutputFormat {
1513
+ if ((apiOutputFormats as readonly string[]).includes(value)) {
1514
+ return value as ApiOutputFormat;
1515
+ }
1516
+ throw new CentrsError({
1517
+ code: "settings/invalid-format",
1518
+ summary: `Unsupported output format: ${value}`,
1519
+ remediation: `Choose one of ${apiOutputFormats.join(", ")}.`,
1520
+ });
1521
+ }
1522
+
1523
+ function resolveErrorFormat(
1524
+ request: ApiRequest,
1525
+ env: Record<string, string | undefined>,
1526
+ ): ApiOutputFormat {
1527
+ try {
1528
+ return resolveApiFormat(request, env).value;
1529
+ } catch {
1530
+ return "json";
1531
+ }
1532
+ }
1533
+
1534
+ function metaFromResolved(
1535
+ resolved: ResolvedApiRequest,
1536
+ validation: EnvelopeValidationMeta,
1537
+ data?: unknown,
1538
+ ): ApiEnvelope["meta"] & { operation: ApiOperationMeta } {
1539
+ const target = resolved.target;
1540
+ const targetSources: Record<string, CoreSettingSource> = {};
1541
+ for (const [field, source] of Object.entries(target.sources)) {
1542
+ targetSources[field] = toCoreSource(source);
1543
+ }
1544
+ const operation: ApiOperationMeta = {
1545
+ kind: "api",
1546
+ objectCount: countResultObjects(data),
1547
+ request: apiRequestSummary(resolved),
1548
+ auth: {
1549
+ username: resolved.auth.username,
1550
+ passwordProvided: resolved.auth.passwordProvided,
1551
+ },
1552
+ };
1553
+ return {
1554
+ target: {
1555
+ input: target.input,
1556
+ host: target.host,
1557
+ port: target.port,
1558
+ baseUrl: target.baseUrl,
1559
+ mac: target.mac,
1560
+ identity: target.identity,
1561
+ recordIndex: target.recordIndex,
1562
+ source: toCoreSource(target.source),
1563
+ sources: targetSources,
1564
+ },
1565
+ via: resolved.via.value,
1566
+ settings: settingsMeta(resolved),
1567
+ validation,
1568
+ operation,
1569
+ };
1570
+ }
1571
+
1572
+ function settingsMeta(resolved: ResolvedApiRequest): CommonSettingsMeta {
1573
+ return {
1574
+ via: toCoreSource(resolved.via.source),
1575
+ host: toCoreSource(resolved.target.hostSource),
1576
+ port: toCoreSource(resolved.target.portSource),
1577
+ timeoutMs: toCoreSource(resolved.timeoutMs.source),
1578
+ format: toCoreSource(resolved.format.source),
1579
+ validate: toCoreSource(resolved.validate.source),
1580
+ insecure: toCoreSource(resolved.insecure.source),
1581
+ username: resolved.auth.usernameSource
1582
+ ? toCoreSource(resolved.auth.usernameSource)
1583
+ : undefined,
1584
+ password: resolved.auth.passwordSource
1585
+ ? toCoreSource(resolved.auth.passwordSource)
1586
+ : undefined,
1587
+ };
1588
+ }
1589
+
1590
+ function apiRequestSummary(resolved: ResolvedApiRequest): ApiRequestSummary {
1591
+ return {
1592
+ endpoint: resolved.endpoint,
1593
+ path: resolved.path,
1594
+ id: resolved.id,
1595
+ method: resolved.method,
1596
+ verb: resolved.verb,
1597
+ write: isApiMutating(resolved.method, resolved.path),
1598
+ listen: resolved.listen,
1599
+ yes: resolved.yes,
1600
+ validate: resolved.validate.value,
1601
+ raw: resolved.raw,
1602
+ format: resolved.format.value,
1603
+ query: resolved.query.length > 0 ? resolved.query : undefined,
1604
+ proplist: resolved.proplist.length > 0 ? resolved.proplist : undefined,
1605
+ };
1606
+ }
1607
+
1608
+ function countResultObjects(data: unknown): number {
1609
+ if (Array.isArray(data)) {
1610
+ return data.length;
1611
+ }
1612
+ if (data === null || data === undefined) {
1613
+ return 0;
1614
+ }
1615
+ if (typeof data === "object" && Object.keys(data).length === 0) {
1616
+ return 0;
1617
+ }
1618
+ return 1;
1619
+ }
1620
+
1621
+ function exhaustiveMethod(value: never): never {
1622
+ throw new Error(`Unhandled api method: ${String(value)}`);
1623
+ }
1624
+
1625
+ function exhaustiveFormat(value: never): never {
1626
+ throw new Error(`Unhandled api output format: ${String(value)}`);
1627
+ }