@tikoci/centrs 0.1.1 → 0.1.2

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