@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/CHANGELOG.md +48 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/common.ts +64 -0
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
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
|
+
}
|