@tikoci/centrs 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -2
- package/GLOSSARY.txt +87 -0
- package/README.md +25 -9
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +177 -0
- package/commands/api/examples.md +428 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +48 -7
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +37 -51
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +40 -15
- package/docs/MATRIX.md +69 -142
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- 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/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +9 -9
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +1627 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +660 -0
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +75 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +29 -0
- package/src/core/error-catalog.ts +69 -0
- package/src/core/fanout.ts +301 -0
- package/src/core/inspect.ts +170 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +134 -70
- package/src/index.ts +166 -9
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +357 -9
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/index.ts +11 -5
- package/src/protocols/native-api.ts +223 -30
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +232 -268
- package/src/retrieve.ts +69 -94
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
- package/commands/stream/README.md +0 -70
package/src/mcp/tools.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
isWriteShaped,
|
|
34
34
|
validateExecuteEnvelope,
|
|
35
35
|
} from "../execute.ts";
|
|
36
|
+
import type { CommentKvUpdate } from "../resolver/comment-kv.ts";
|
|
37
|
+
import { parseBbox, parseNear } from "../resolver/geo.ts";
|
|
36
38
|
import { parseDuration } from "../resolver/settings.ts";
|
|
37
39
|
import { buildRetrieveErrorEnvelope, retrieve } from "../retrieve.ts";
|
|
38
40
|
import {
|
|
@@ -416,6 +418,24 @@ export const devicesInputShape = {
|
|
|
416
418
|
.describe(
|
|
417
419
|
"Group filter for op=list; first-class group field for op=add/set.",
|
|
418
420
|
),
|
|
421
|
+
where: z
|
|
422
|
+
.array(z.string())
|
|
423
|
+
.optional()
|
|
424
|
+
.describe(
|
|
425
|
+
"op=list device-class filters `attr=value` (repeatable, AND-combined; e.g. board=RB5009 or lat=37.7749).",
|
|
426
|
+
),
|
|
427
|
+
near: z
|
|
428
|
+
.string()
|
|
429
|
+
.optional()
|
|
430
|
+
.describe(
|
|
431
|
+
"op=list geo filter `<lat>,<lon>,<radius>` (lat-first; radius m/km/mi/ft, bare = km).",
|
|
432
|
+
),
|
|
433
|
+
bbox: z
|
|
434
|
+
.string()
|
|
435
|
+
.optional()
|
|
436
|
+
.describe(
|
|
437
|
+
"op=list geo filter `<south>,<west>,<north>,<east>` (lat-first bounding box).",
|
|
438
|
+
),
|
|
419
439
|
user: z.string().optional().describe("CDB username for op=add/set."),
|
|
420
440
|
password: z
|
|
421
441
|
.string()
|
|
@@ -454,6 +474,30 @@ export const devicesInputShape = {
|
|
|
454
474
|
.boolean()
|
|
455
475
|
.optional()
|
|
456
476
|
.describe("For op=set, reject comment keys outside the allowlist."),
|
|
477
|
+
lat: z
|
|
478
|
+
.number()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe(
|
|
481
|
+
"Latitude in decimal degrees (-90..90) for op=add/set; a comment-kv fact, paired with lon (issue #146).",
|
|
482
|
+
),
|
|
483
|
+
lon: z
|
|
484
|
+
.number()
|
|
485
|
+
.optional()
|
|
486
|
+
.describe(
|
|
487
|
+
"Longitude in decimal degrees (-180..180) for op=add/set; a comment-kv fact, paired with lat.",
|
|
488
|
+
),
|
|
489
|
+
altitude: z
|
|
490
|
+
.number()
|
|
491
|
+
.optional()
|
|
492
|
+
.describe(
|
|
493
|
+
"Altitude in meters for op=add/set (may be negative); a comment-kv fact.",
|
|
494
|
+
),
|
|
495
|
+
altitudeType: z
|
|
496
|
+
.enum(["MSL", "AGL"])
|
|
497
|
+
.optional()
|
|
498
|
+
.describe(
|
|
499
|
+
"Vertical datum for altitude, op=add/set (default MSL when altitude is set).",
|
|
500
|
+
),
|
|
457
501
|
confirm: z
|
|
458
502
|
.boolean()
|
|
459
503
|
.optional()
|
|
@@ -478,6 +522,56 @@ function requireDevicesTarget(
|
|
|
478
522
|
});
|
|
479
523
|
}
|
|
480
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Lower the typed `lat`/`lon`/`altitude`/`altitudeType` MCP args into comment-kv
|
|
527
|
+
* updates — the same core surface the CLI's `--lat`/`--lon`/`--gps` flags lower
|
|
528
|
+
* into (`src/cli/devices.ts`). Light touch: no alias handling here (the MCP
|
|
529
|
+
* schema only exposes the canonical field names), range/enum/pairing
|
|
530
|
+
* validation happens once in `validateCommentKvUpdates` (`src/devices.ts`).
|
|
531
|
+
*/
|
|
532
|
+
function buildGeoUpdates(args: DevicesArgs): CommentKvUpdate[] {
|
|
533
|
+
const updates: CommentKvUpdate[] = [];
|
|
534
|
+
if (args.lat !== undefined) {
|
|
535
|
+
updates.push({ key: "lat", value: String(args.lat) });
|
|
536
|
+
}
|
|
537
|
+
if (args.lon !== undefined) {
|
|
538
|
+
updates.push({ key: "lon", value: String(args.lon) });
|
|
539
|
+
}
|
|
540
|
+
if (args.altitude !== undefined) {
|
|
541
|
+
updates.push({ key: "altitude", value: String(args.altitude) });
|
|
542
|
+
}
|
|
543
|
+
if (args.altitudeType !== undefined) {
|
|
544
|
+
updates.push({ key: "altitude-type", value: args.altitudeType });
|
|
545
|
+
}
|
|
546
|
+
return updates;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Parse the MCP `where` array (`["attr=value", …]`) into device-class clauses
|
|
551
|
+
* for op=list. Geo-alias canonicalization happens centrally in `matchesWhere`
|
|
552
|
+
* (`src/resolver/facts.ts`), so this only splits on the first `=`.
|
|
553
|
+
*/
|
|
554
|
+
function parseMcpWhereClauses(
|
|
555
|
+
where: readonly string[] | undefined,
|
|
556
|
+
): { key: string; value: string }[] | undefined {
|
|
557
|
+
if (where === undefined) {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
return where.map((clause) => {
|
|
561
|
+
const eq = clause.indexOf("=");
|
|
562
|
+
if (eq <= 0) {
|
|
563
|
+
throw new CentrsError({
|
|
564
|
+
code: "input/invalid-command",
|
|
565
|
+
summary: `where clause must be \`attr=value\`; received: ${clause}`,
|
|
566
|
+
remediation:
|
|
567
|
+
"Pass device-class filters like `board=RB5009` or `lat=37.7749`.",
|
|
568
|
+
context: { where: clause },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return { key: clause.slice(0, eq), value: clause.slice(eq + 1) };
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
481
575
|
function resolveRecordType(recordType: string | undefined): number | undefined {
|
|
482
576
|
if (recordType === undefined) {
|
|
483
577
|
return undefined;
|
|
@@ -502,9 +596,49 @@ export async function handleDevices(
|
|
|
502
596
|
): Promise<McpEnvelope> {
|
|
503
597
|
try {
|
|
504
598
|
const cdb = await loadAllowlistCdb(config);
|
|
599
|
+
// GPS *setter* fields write via add/set only; the *query* selectors
|
|
600
|
+
// (where/near/bbox) select via list only. Reject the wrong-op combination
|
|
601
|
+
// instead of silently dropping the caller's input (mirrors the CLI's
|
|
602
|
+
// per-subcommand flag guard in src/cli/devices.ts).
|
|
603
|
+
if (
|
|
604
|
+
args.op !== "add" &&
|
|
605
|
+
args.op !== "set" &&
|
|
606
|
+
(args.lat !== undefined ||
|
|
607
|
+
args.lon !== undefined ||
|
|
608
|
+
args.altitude !== undefined ||
|
|
609
|
+
args.altitudeType !== undefined)
|
|
610
|
+
) {
|
|
611
|
+
throw new CentrsError({
|
|
612
|
+
code: "input/invalid-command",
|
|
613
|
+
summary: `GPS fields (lat/lon/altitude/altitudeType) are not valid for op="${args.op}".`,
|
|
614
|
+
remediation:
|
|
615
|
+
"Store GPS with op=add or op=set; drop the GPS fields for list/show/groups/edit/remove.",
|
|
616
|
+
context: { op: args.op },
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
if (
|
|
620
|
+
args.op !== "list" &&
|
|
621
|
+
(args.where !== undefined ||
|
|
622
|
+
args.near !== undefined ||
|
|
623
|
+
args.bbox !== undefined)
|
|
624
|
+
) {
|
|
625
|
+
throw new CentrsError({
|
|
626
|
+
code: "input/invalid-command",
|
|
627
|
+
summary: `Query selectors (where/near/bbox) are only valid for op="list", not op="${args.op}".`,
|
|
628
|
+
remediation:
|
|
629
|
+
"Use op=list with where/near/bbox to query the registry; for op=show name a target directly.",
|
|
630
|
+
context: { op: args.op },
|
|
631
|
+
});
|
|
632
|
+
}
|
|
505
633
|
switch (args.op) {
|
|
506
634
|
case "list":
|
|
507
|
-
return listDevices({
|
|
635
|
+
return listDevices({
|
|
636
|
+
cdb,
|
|
637
|
+
group: args.group,
|
|
638
|
+
where: parseMcpWhereClauses(args.where),
|
|
639
|
+
near: args.near !== undefined ? parseNear(args.near) : undefined,
|
|
640
|
+
bbox: args.bbox !== undefined ? parseBbox(args.bbox) : undefined,
|
|
641
|
+
});
|
|
508
642
|
case "groups":
|
|
509
643
|
return listGroups({ cdb, withMembers: true });
|
|
510
644
|
case "show": {
|
|
@@ -527,6 +661,7 @@ export async function handleDevices(
|
|
|
527
661
|
profile: args.profile,
|
|
528
662
|
session: args.session,
|
|
529
663
|
comment: args.comment,
|
|
664
|
+
commentKvUpdates: buildGeoUpdates(args),
|
|
530
665
|
savedPassword: args.savedPassword,
|
|
531
666
|
force: args.force,
|
|
532
667
|
}),
|
|
@@ -542,7 +677,7 @@ export async function handleDevices(
|
|
|
542
677
|
await setDevice({
|
|
543
678
|
cdb,
|
|
544
679
|
target,
|
|
545
|
-
updates: args.updates,
|
|
680
|
+
updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
|
|
546
681
|
user: args.user,
|
|
547
682
|
password: args.password,
|
|
548
683
|
group: args.group,
|
package/src/protocols/adapter.ts
CHANGED
|
@@ -106,6 +106,54 @@ export interface ProtocolExecuteResult {
|
|
|
106
106
|
ret?: string;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* RouterOS verb the `api` orchestrator resolved from the HTTP method (the gh-api
|
|
111
|
+
* `-X` map: `GET`→print, `PUT`→add, `PATCH`→set, `DELETE`→remove, `POST`→run).
|
|
112
|
+
* `print` covers list, get-singleton, and get-one (disambiguated by `id`).
|
|
113
|
+
*/
|
|
114
|
+
export type ApiVerb = "print" | "add" | "set" | "remove" | "run";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A normalized `api` request the orchestrator hands to a transport. It is
|
|
118
|
+
* transport-agnostic: the REST adapter maps the verb to an HTTP method + URL
|
|
119
|
+
* (id-in-path, `.query`/`.proplist` body), the native adapter maps it to a
|
|
120
|
+
* tagged `talk` (`=.id=`, `?`-prefixed queries, `=.proplist=`). The orchestrator
|
|
121
|
+
* owns method→verb, validation, and the write gate; the adapter only executes.
|
|
122
|
+
*/
|
|
123
|
+
export interface ProtocolApiRequest {
|
|
124
|
+
verb: ApiVerb;
|
|
125
|
+
/** Slash path without id, e.g. `/ip/address`. For `run`, the command target (`/interface/monitor-traffic`); ignored when `script` is set. */
|
|
126
|
+
path: string;
|
|
127
|
+
/** Object id (`*1`) for get-one/set/remove — REST puts it in the URL, native in `=.id=`. */
|
|
128
|
+
id?: string;
|
|
129
|
+
/** Body fields (string values), the JSON object for add/set or the args for a `run` command. */
|
|
130
|
+
attributes?: Record<string, string>;
|
|
131
|
+
/** Row filter in REST `.query` form (no leading `?`), e.g. `["type=ether", "#!"]`; native prefixes each with `?`. */
|
|
132
|
+
query?: readonly string[];
|
|
133
|
+
/** Property projection → REST `.proplist` / native `=.proplist=`. */
|
|
134
|
+
proplist?: readonly string[];
|
|
135
|
+
/** Raw console line for the `/execute` script form (`run`); when set, `path`/`attributes` are ignored. */
|
|
136
|
+
script?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Result of an `apiRequest` round-trip: the response body shaped for the envelope. */
|
|
140
|
+
export interface ProtocolApiResult {
|
|
141
|
+
/** REST returns the body verbatim (array / object / scalar); native re-maps `!re` records to the same rest-style shape. */
|
|
142
|
+
data: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Options for an open-ended `listen` follow. */
|
|
146
|
+
export interface ProtocolListenOptions {
|
|
147
|
+
/** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
|
|
148
|
+
signal?: AbortSignal;
|
|
149
|
+
/**
|
|
150
|
+
* Fired once the subscription is on the wire (connection up, listen sentence
|
|
151
|
+
* written) — a real "listening" barrier for callers/tests that must act only
|
|
152
|
+
* after the follow is established, instead of a blind timer.
|
|
153
|
+
*/
|
|
154
|
+
onListening?: () => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
109
157
|
/**
|
|
110
158
|
* Runtime transport seam. The orchestrator drives validation and data fetch
|
|
111
159
|
* through these operations so REST and native-api share the same pipeline and
|
|
@@ -124,6 +172,18 @@ export interface ProtocolAdapter {
|
|
|
124
172
|
list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
|
|
125
173
|
/** Run a CLI-shaped command (WP-1c write surface). */
|
|
126
174
|
execute(request: ProtocolExecuteRequest): Promise<ProtocolExecuteResult>;
|
|
175
|
+
/** Run a normalized structured `api` request (the gh-api passthrough surface). */
|
|
176
|
+
apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
|
|
177
|
+
/**
|
|
178
|
+
* Open-ended `/listen` follow (native-api only). Yields one rest-style change
|
|
179
|
+
* record per `!re` frame (deletions carry `.dead`), until cancelled via
|
|
180
|
+
* `options.signal` or the consumer stopping. REST and console transports
|
|
181
|
+
* reject with `transport/capability-unsupported`.
|
|
182
|
+
*/
|
|
183
|
+
listen(
|
|
184
|
+
request: ProtocolApiRequest,
|
|
185
|
+
options: ProtocolListenOptions,
|
|
186
|
+
): AsyncIterable<Record<string, unknown>>;
|
|
127
187
|
/** Release any underlying connection. Safe to call when never connected. */
|
|
128
188
|
close(): Promise<void>;
|
|
129
189
|
}
|
|
@@ -207,10 +267,105 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
207
267
|
return normalizeRestExecute(data);
|
|
208
268
|
}
|
|
209
269
|
|
|
270
|
+
async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
|
|
271
|
+
const base = request.path.replace(/\/$/, "");
|
|
272
|
+
switch (request.verb) {
|
|
273
|
+
case "print": {
|
|
274
|
+
const hasQuery = (request.query?.length ?? 0) > 0;
|
|
275
|
+
const hasProplist = (request.proplist?.length ?? 0) > 0;
|
|
276
|
+
if (request.id && !hasQuery && !hasProplist) {
|
|
277
|
+
// GET one by id: RouterOS REST addresses a single object in the URL.
|
|
278
|
+
return {
|
|
279
|
+
data: await this.requestRest("GET", `${base}/${request.id}`),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (request.id || hasQuery || hasProplist) {
|
|
283
|
+
// A GET cannot carry a body, so `.query`/`.proplist` projection (and an
|
|
284
|
+
// id, folded into `.query` as `.id=`) rides a POST to the `/print`
|
|
285
|
+
// sub-endpoint — the documented REST idiom, matching native's `?`-words.
|
|
286
|
+
const data = await this.requestRest(
|
|
287
|
+
"POST",
|
|
288
|
+
`${base}/print`,
|
|
289
|
+
restQueryBody(request),
|
|
290
|
+
);
|
|
291
|
+
// An id addresses one row → unwrap to a single object, matching the
|
|
292
|
+
// GET-by-id shape and native's `?.id=` read.
|
|
293
|
+
if (request.id && Array.isArray(data)) {
|
|
294
|
+
return { data: data[0] ?? null };
|
|
295
|
+
}
|
|
296
|
+
return { data };
|
|
297
|
+
}
|
|
298
|
+
return { data: await this.requestRest("GET", base) };
|
|
299
|
+
}
|
|
300
|
+
case "add":
|
|
301
|
+
return {
|
|
302
|
+
data: await this.requestRest("PUT", base, request.attributes ?? {}),
|
|
303
|
+
};
|
|
304
|
+
case "set":
|
|
305
|
+
return {
|
|
306
|
+
data: await this.requestRest(
|
|
307
|
+
"PATCH",
|
|
308
|
+
`${base}/${request.id}`,
|
|
309
|
+
request.attributes ?? {},
|
|
310
|
+
),
|
|
311
|
+
};
|
|
312
|
+
case "remove":
|
|
313
|
+
return {
|
|
314
|
+
data: await this.requestRest("DELETE", `${base}/${request.id}`),
|
|
315
|
+
};
|
|
316
|
+
case "run": {
|
|
317
|
+
if (request.script !== undefined) {
|
|
318
|
+
// Sync script run: `as-string` makes `/rest/execute` block and return the
|
|
319
|
+
// captured output rather than scheduling an async job (CHR-grounded).
|
|
320
|
+
const body = await this.requestRest("POST", "/execute", {
|
|
321
|
+
script: request.script,
|
|
322
|
+
"as-string": "",
|
|
323
|
+
});
|
|
324
|
+
const result = normalizeRestExecute(body);
|
|
325
|
+
return { data: result.ret ?? result.records };
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
data: await this.requestRest("POST", base, request.attributes ?? {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
default:
|
|
332
|
+
return exhaustiveApiVerb(request.verb);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// REST cannot hold an open-ended follow (the 60s cap); listen is native-only.
|
|
337
|
+
// biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
|
|
338
|
+
async *listen(): AsyncGenerator<Record<string, unknown>> {
|
|
339
|
+
throw new CentrsError({
|
|
340
|
+
code: "transport/capability-unsupported",
|
|
341
|
+
summary: "REST cannot follow an open-ended `--stream` (60s cap).",
|
|
342
|
+
remediation:
|
|
343
|
+
"Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
|
|
344
|
+
context: { protocol: this.protocol, capability: "listen" },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
210
348
|
async close(): Promise<void> {
|
|
211
349
|
// REST is stateless; nothing to release.
|
|
212
350
|
}
|
|
213
351
|
|
|
352
|
+
private async requestRest(
|
|
353
|
+
method: string,
|
|
354
|
+
path: string,
|
|
355
|
+
body?: Record<string, unknown>,
|
|
356
|
+
): Promise<unknown> {
|
|
357
|
+
const init: RequestInit =
|
|
358
|
+
body === undefined
|
|
359
|
+
? { method }
|
|
360
|
+
: {
|
|
361
|
+
method,
|
|
362
|
+
body: JSON.stringify(body),
|
|
363
|
+
headers: { "Content-Type": "application/json" },
|
|
364
|
+
};
|
|
365
|
+
const response = await this.fetchRest(path, init);
|
|
366
|
+
return response.data;
|
|
367
|
+
}
|
|
368
|
+
|
|
214
369
|
private async restGet(path: string): Promise<unknown> {
|
|
215
370
|
const response = await this.fetchRest(path, { method: "GET" });
|
|
216
371
|
return response.data;
|
|
@@ -455,23 +610,135 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
455
610
|
async execute(
|
|
456
611
|
request: ProtocolExecuteRequest,
|
|
457
612
|
): Promise<ProtocolExecuteResult> {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
) {
|
|
613
|
+
if (request.script !== undefined) {
|
|
614
|
+
return normalizeNativeExecute(await this.executeScript(request.script));
|
|
615
|
+
}
|
|
616
|
+
const command: NativeApiCommand = {
|
|
617
|
+
command: `${request.path.replace(/\/$/, "")}/${request.command}`,
|
|
618
|
+
};
|
|
619
|
+
if (request.attributes && Object.keys(request.attributes).length > 0) {
|
|
466
620
|
command.attributes = request.attributes;
|
|
467
621
|
}
|
|
468
|
-
if (
|
|
622
|
+
if (request.queries && request.queries.length > 0) {
|
|
469
623
|
command.queries = request.queries;
|
|
470
624
|
}
|
|
471
625
|
const replies = await this.talk(command);
|
|
472
626
|
return { records: repliesToRecords(replies) };
|
|
473
627
|
}
|
|
474
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Run a script through native `/execute` with `as-string`, the sole shared
|
|
631
|
+
* path for both the CLI-shaped `execute` command and the `api` run+script
|
|
632
|
+
* verb: without `as-string` the native API schedules a background job and
|
|
633
|
+
* returns only its id rather than the captured output (CHR-grounded —
|
|
634
|
+
* mirrors REST `/rest/execute`).
|
|
635
|
+
*/
|
|
636
|
+
private async executeScript(
|
|
637
|
+
script: string,
|
|
638
|
+
): Promise<Record<string, string>[]> {
|
|
639
|
+
const replies = await this.talk({
|
|
640
|
+
command: "/execute",
|
|
641
|
+
attributes: { script, "as-string": "" },
|
|
642
|
+
});
|
|
643
|
+
return repliesToRecords(replies);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
|
|
647
|
+
const base = request.path.replace(/\/$/, "");
|
|
648
|
+
switch (request.verb) {
|
|
649
|
+
case "print": {
|
|
650
|
+
const command: NativeApiCommand = { command: `${base}/print` };
|
|
651
|
+
const queries: string[] = [];
|
|
652
|
+
// REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
|
|
653
|
+
for (const word of request.query ?? []) {
|
|
654
|
+
queries.push(`?${word}`);
|
|
655
|
+
}
|
|
656
|
+
// No native get-one-by-id shorthand: address a single row with `?.id=`.
|
|
657
|
+
if (request.id) {
|
|
658
|
+
queries.push(`?.id=${request.id}`);
|
|
659
|
+
}
|
|
660
|
+
if (queries.length > 0) {
|
|
661
|
+
command.queries = queries;
|
|
662
|
+
}
|
|
663
|
+
if (request.proplist && request.proplist.length > 0) {
|
|
664
|
+
command.proplist = request.proplist;
|
|
665
|
+
}
|
|
666
|
+
const records = repliesToRecords(await this.talk(command));
|
|
667
|
+
return { data: request.id ? (records[0] ?? null) : records };
|
|
668
|
+
}
|
|
669
|
+
case "add": {
|
|
670
|
+
const records = repliesToRecords(
|
|
671
|
+
await this.talk({
|
|
672
|
+
command: `${base}/add`,
|
|
673
|
+
attributes: request.attributes ?? {},
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
return { data: restStyleMutationData(records) };
|
|
677
|
+
}
|
|
678
|
+
case "set": {
|
|
679
|
+
const records = repliesToRecords(
|
|
680
|
+
await this.talk({
|
|
681
|
+
command: `${base}/set`,
|
|
682
|
+
attributes: {
|
|
683
|
+
...(request.attributes ?? {}),
|
|
684
|
+
".id": request.id ?? "",
|
|
685
|
+
},
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
return { data: restStyleMutationData(records) };
|
|
689
|
+
}
|
|
690
|
+
case "remove": {
|
|
691
|
+
const records = repliesToRecords(
|
|
692
|
+
await this.talk({
|
|
693
|
+
command: `${base}/remove`,
|
|
694
|
+
attributes: { ".id": request.id ?? "" },
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
return { data: restStyleMutationData(records) };
|
|
698
|
+
}
|
|
699
|
+
case "run": {
|
|
700
|
+
if (request.script !== undefined) {
|
|
701
|
+
const records = await this.executeScript(request.script);
|
|
702
|
+
return { data: restStyleRunData(records) };
|
|
703
|
+
}
|
|
704
|
+
const command: NativeApiCommand = { command: base };
|
|
705
|
+
if (request.attributes && Object.keys(request.attributes).length > 0) {
|
|
706
|
+
command.attributes = request.attributes;
|
|
707
|
+
}
|
|
708
|
+
return { data: repliesToRecords(await this.talk(command)) };
|
|
709
|
+
}
|
|
710
|
+
default:
|
|
711
|
+
return exhaustiveApiVerb(request.verb);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async *listen(
|
|
716
|
+
request: ProtocolApiRequest,
|
|
717
|
+
options: ProtocolListenOptions,
|
|
718
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
719
|
+
const base = request.path.replace(/\/$/, "");
|
|
720
|
+
const command: NativeApiCommand = { command: `${base}/listen` };
|
|
721
|
+
// A listen can filter which changes it follows with the same `?`-words /
|
|
722
|
+
// `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
|
|
723
|
+
const queries = (request.query ?? []).map((word) => `?${word}`);
|
|
724
|
+
// An addressed row (`ip/address/*1 --stream`) follows just that row, the
|
|
725
|
+
// same `?.id=` mapping one-shot native reads use.
|
|
726
|
+
if (request.id) {
|
|
727
|
+
queries.push(`?.id=${request.id}`);
|
|
728
|
+
}
|
|
729
|
+
if (queries.length > 0) {
|
|
730
|
+
command.queries = queries;
|
|
731
|
+
}
|
|
732
|
+
if (request.proplist && request.proplist.length > 0) {
|
|
733
|
+
command.proplist = request.proplist;
|
|
734
|
+
}
|
|
735
|
+
const session = await this.connect();
|
|
736
|
+
for await (const reply of session.listen(command, options)) {
|
|
737
|
+
// Each `!re` becomes a rest-style record (string values; `.dead` preserved).
|
|
738
|
+
yield { ...reply.attributes };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
475
742
|
async close(): Promise<void> {
|
|
476
743
|
this.session?.close();
|
|
477
744
|
this.session = undefined;
|
|
@@ -614,6 +881,15 @@ class MacTelnetAdapter implements ProtocolAdapter {
|
|
|
614
881
|
return { records: [], ret: output };
|
|
615
882
|
}
|
|
616
883
|
|
|
884
|
+
apiRequest(): Promise<ProtocolApiResult> {
|
|
885
|
+
return Promise.reject(this.unsupported("structured api requests"));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
|
|
889
|
+
async *listen(): AsyncGenerator<Record<string, unknown>> {
|
|
890
|
+
throw this.unsupported("open-ended listen streaming");
|
|
891
|
+
}
|
|
892
|
+
|
|
617
893
|
async close(): Promise<void> {
|
|
618
894
|
this.console?.close();
|
|
619
895
|
this.transport?.close();
|
|
@@ -756,11 +1032,83 @@ class SshExecAdapter implements ProtocolAdapter {
|
|
|
756
1032
|
return { records: [], ret: output };
|
|
757
1033
|
}
|
|
758
1034
|
|
|
1035
|
+
apiRequest(): Promise<ProtocolApiResult> {
|
|
1036
|
+
return Promise.reject(this.unsupported("structured api requests"));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
|
|
1040
|
+
async *listen(): AsyncGenerator<Record<string, unknown>> {
|
|
1041
|
+
throw this.unsupported("open-ended listen streaming");
|
|
1042
|
+
}
|
|
1043
|
+
|
|
759
1044
|
close(): Promise<void> {
|
|
760
1045
|
return Promise.resolve();
|
|
761
1046
|
}
|
|
762
1047
|
}
|
|
763
1048
|
|
|
1049
|
+
/** REST `.query` / `.proplist` projection body for a POST `/print`. */
|
|
1050
|
+
function restQueryBody(request: ProtocolApiRequest): Record<string, unknown> {
|
|
1051
|
+
const body: Record<string, unknown> = {};
|
|
1052
|
+
// Fold an id into `.query` as `.id=<id>` so id+query/proplist reads work over
|
|
1053
|
+
// REST exactly as they do over native (`?.id=` plus the `?`-words).
|
|
1054
|
+
const query = [
|
|
1055
|
+
...(request.id ? [`.id=${request.id}`] : []),
|
|
1056
|
+
...(request.query ?? []),
|
|
1057
|
+
];
|
|
1058
|
+
if (query.length > 0) {
|
|
1059
|
+
body[".query"] = query;
|
|
1060
|
+
}
|
|
1061
|
+
if (request.proplist && request.proplist.length > 0) {
|
|
1062
|
+
body[".proplist"] = request.proplist;
|
|
1063
|
+
}
|
|
1064
|
+
return body;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Re-map a native add/set/remove reply to the rest-style shape. `add` returns the
|
|
1069
|
+
* new id in `=ret=`; surface it as `{".id": ret}` (REST returns the created
|
|
1070
|
+
* object with `.id`). `set`/`remove` reply with a bare `!done` (no records) → no
|
|
1071
|
+
* meaningful body, mirrored as `null`.
|
|
1072
|
+
*/
|
|
1073
|
+
function restStyleMutationData(
|
|
1074
|
+
records: readonly Record<string, string>[],
|
|
1075
|
+
): unknown {
|
|
1076
|
+
if (records.length === 0) {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
const record = records[0] ?? {};
|
|
1080
|
+
const ret = record["ret"];
|
|
1081
|
+
if (typeof ret === "string" && /^\*[0-9A-F]+$/i.test(ret)) {
|
|
1082
|
+
return { ".id": ret };
|
|
1083
|
+
}
|
|
1084
|
+
return records.length === 1 ? record : records;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Re-map a native `/execute` reply: prefer the captured `ret` string, else the records. */
|
|
1088
|
+
function restStyleRunData(records: readonly Record<string, string>[]): unknown {
|
|
1089
|
+
const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
|
|
1090
|
+
if (typeof ret === "string") {
|
|
1091
|
+
return ret;
|
|
1092
|
+
}
|
|
1093
|
+
return records.length === 1 ? records[0] : records;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function exhaustiveApiVerb(verb: never): never {
|
|
1097
|
+
throw new Error(`Unhandled api verb: ${String(verb)}`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Re-map an `as-string` native `/execute` reply into `ProtocolExecuteResult`,
|
|
1101
|
+
* mirroring `normalizeRestExecute`'s `ret`-vs-records split. */
|
|
1102
|
+
function normalizeNativeExecute(
|
|
1103
|
+
records: readonly Record<string, string>[],
|
|
1104
|
+
): ProtocolExecuteResult {
|
|
1105
|
+
const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
|
|
1106
|
+
if (typeof ret === "string") {
|
|
1107
|
+
return { records: [], ret };
|
|
1108
|
+
}
|
|
1109
|
+
return { records: [...records] };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
764
1112
|
function normalizeRestExecute(data: unknown): ProtocolExecuteResult {
|
|
765
1113
|
if (Array.isArray(data)) {
|
|
766
1114
|
return { records: data.filter(isPlainObject) as Record<string, string>[] };
|