@tikoci/centrs 0.1.6 → 0.1.7
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 +108 -1
- package/README.md +8 -4
- package/commands/api/README.md +21 -10
- package/commands/api/examples.md +70 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +29 -15
- package/commands/execute/examples.md +89 -0
- package/commands/explain/README.md +12 -1
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/examples.md +20 -0
- package/docs/CLI.md +1 -1
- package/docs/CONSTITUTION.md +11 -6
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/package.json +1 -1
- package/src/api.ts +109 -25
- package/src/cli/devices.ts +16 -3
- package/src/core/error-catalog.ts +14 -0
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +22 -22
- package/src/core/routeros-errors.ts +66 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +1 -1
- package/src/execute.ts +22 -51
- package/src/explain/catalog.ts +8 -8
- package/src/explain/menus.ts +4 -4
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +1 -1
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve.ts +17 -4
package/docs/CONSTITUTION.md
CHANGED
|
@@ -84,8 +84,10 @@ validator, with CHR evidence.
|
|
|
84
84
|
`expected`. Rejection forms are `(evl bad parameter <name> (line N column M)
|
|
85
85
|
/path)`, `(<%% bad command name <name> (line N column M) …)`, and the
|
|
86
86
|
line-anchored `expected … (line N column M)` (spellings: `expected end of
|
|
87
|
-
command`, `expected command name`, `expected input value`)
|
|
88
|
-
|
|
87
|
+
command`, `expected command name`, `expected input value`), `missing … (line
|
|
88
|
+
N column M)` (`missing closing brace`, `missing value for where`; GH#375) and
|
|
89
|
+
`syntax error (line N column M)`. Every bare form is anchored at a line start
|
|
90
|
+
with the position suffix, because an accepted return can echo those words. That text rides the **HTTP-200 `ret` value** (REST,
|
|
89
91
|
`as-string`), the native `as-string` `ret`, and the console's printed output
|
|
90
92
|
alike — so a single `:put [:parse ...]` covers both the syntax and the
|
|
91
93
|
**unknown-attribute** gate on every transport. centrs sends `as-string` and now
|
|
@@ -535,8 +537,10 @@ as bibliography entries in `GLOSSARY.txt` — e.g. `netmiko`,
|
|
|
535
537
|
### Canonicalizer ownership
|
|
536
538
|
|
|
537
539
|
centrs owns the **script-vs-structured execution gate** — the load-bearing
|
|
538
|
-
discriminator for
|
|
539
|
-
|
|
540
|
+
discriminator for validation and transport shape; widening what counts as
|
|
541
|
+
`structured` is a product regression. Write confirmation is a separate,
|
|
542
|
+
fail-closed safety predicate over the original CLI string: falling back to raw
|
|
543
|
+
script transport must never turn a write into a read. The shared,
|
|
540
544
|
pure command canonicalizer that `rosetta` / `lsp-routeros-ts` publish is for
|
|
541
545
|
canonicalization only, never the structured-mode predicate. The gate contract,
|
|
542
546
|
its pinning test, and the parser-vendoring preconditions are documented in
|
|
@@ -546,5 +550,6 @@ its pinning test, and the parser-vendoring preconditions are documented in
|
|
|
546
550
|
Richer schema-free analysis must distinguish **ambiguous** (multiple plausible
|
|
547
551
|
readings) from **unknown** (no safe reading) and abstain in both cases rather than
|
|
548
552
|
inventing a command, path, symbol class, or transport mapping. These explain-only
|
|
549
|
-
verdicts never widen or reinterpret the execution gate's `mode
|
|
550
|
-
|
|
553
|
+
verdicts never widen or reinterpret the execution gate's `mode`. The execution
|
|
554
|
+
safety predicate remains deliberately conservative and independent of richer
|
|
555
|
+
explain-only inference.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# `cdb/create-failed`
|
|
2
|
+
|
|
3
|
+
A new CDB file could not be created at the resolved location.
|
|
4
|
+
|
|
5
|
+
`centrs devices init` creates an empty CDB at the path from `--cdb-file`,
|
|
6
|
+
`CENTRS_CDB_FILE`, or the default `~/.config/tikoci/winbox.cdb`, making any
|
|
7
|
+
missing parent directories. This error means the filesystem refused: typically
|
|
8
|
+
a directory on the path is not writable by the current user, or a path
|
|
9
|
+
component is a file rather than a directory. The underlying filesystem error
|
|
10
|
+
(for example `EACCES`) is preserved as the error's `cause`; run
|
|
11
|
+
with `--json` to see it.
|
|
12
|
+
|
|
13
|
+
Nothing is written when this fails.
|
|
14
|
+
|
|
15
|
+
## Fix
|
|
16
|
+
|
|
17
|
+
- Check the permissions of the directories on the path shown in the error.
|
|
18
|
+
- Or choose a writable location:
|
|
19
|
+
`centrs devices init --cdb-file PATH`, then pass the same `--cdb-file PATH`
|
|
20
|
+
(or set `CENTRS_CDB_FILE`) on later commands.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# `cdb/file-exists`
|
|
2
|
+
|
|
3
|
+
`devices init` found a CDB already at the resolved location and wrote nothing.
|
|
4
|
+
|
|
5
|
+
This is a warning on a successful result, not an error. Like `git init`,
|
|
6
|
+
`centrs devices init` never overwrites an existing CDB. When the file there
|
|
7
|
+
already loads as a CDB, `init` succeeds with `data.created: false` and
|
|
8
|
+
`data.recordCount` set to the number of records it holds, and leaves the file
|
|
9
|
+
byte-for-byte untouched. A bootstrap script can therefore run `init`
|
|
10
|
+
unconditionally.
|
|
11
|
+
|
|
12
|
+
A file at that path that does not load as a CDB is not reported here. `init`
|
|
13
|
+
fails with the load error instead (`cdb/parse-failed`, or
|
|
14
|
+
`cdb/password-required` for an encrypted CDB without `--cdb-password`), and
|
|
15
|
+
still leaves the file alone.
|
|
16
|
+
|
|
17
|
+
## Fix
|
|
18
|
+
|
|
19
|
+
Usually nothing: the CDB you asked for exists.
|
|
20
|
+
|
|
21
|
+
- To see what it holds: `centrs devices list --cdb-file PATH`.
|
|
22
|
+
- To start fresh, pick a different path (`centrs devices init --cdb-file
|
|
23
|
+
NEW_PATH`), or move the existing file aside yourself; centrs never deletes it.
|
|
@@ -2,8 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
The target string is invalid for the requested operation.
|
|
4
4
|
|
|
5
|
+
A command's `<router>` is looked up in the CDB first: its `target`, or an
|
|
6
|
+
`identity=` / `mac=` / `ip=` comment lookup key. When nothing matches, centrs
|
|
7
|
+
treats the string as a host. This error means it is not a valid host either,
|
|
8
|
+
or it is a URL that carries a path. Common causes:
|
|
9
|
+
|
|
10
|
+
- **`quickchr:NAME`**: there is no `quickchr:` target prefix. A quickchr VM is
|
|
11
|
+
selected with a flag: `--quickchr NAME`.
|
|
12
|
+
- **A typo or stray characters**, such as spaces or an extra `:` (`a b`,
|
|
13
|
+
`bad:host:name`).
|
|
14
|
+
- **A base URL with a path**, such as `http://192.0.2.1/rest`. The RouterOS
|
|
15
|
+
menu path is a separate argument.
|
|
16
|
+
|
|
17
|
+
The rejected string is in the error's `context.target`.
|
|
18
|
+
|
|
5
19
|
## Fix
|
|
6
20
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
21
|
+
Pass one of the accepted target forms:
|
|
22
|
+
|
|
23
|
+
| Form | Example |
|
|
24
|
+
| ---- | ------- |
|
|
25
|
+
| CDB target or lookup key | `edge1` (see `centrs devices list`) |
|
|
26
|
+
| IP address or hostname, optional `:port` | `192.0.2.1`, `router.example:8080` |
|
|
27
|
+
| Base URL without a path | `https://192.0.2.1:8443` |
|
|
28
|
+
| quickchr VM | `--quickchr NAME` (a flag, not a prefix) |
|
|
29
|
+
|
|
30
|
+
With an explicit `--cdb-file` and no `__default__` fallback record, a string
|
|
31
|
+
that matches no record stops earlier with `cdb/not-found-target`.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# `routeros/invalid-value`
|
|
2
2
|
|
|
3
|
-
RouterOS rejected
|
|
4
|
-
|
|
3
|
+
RouterOS rejected a supplied value. When RouterOS names the offending argument,
|
|
4
|
+
it is captured in `context.argument`.
|
|
5
5
|
|
|
6
6
|
## Typical RouterOS strings
|
|
7
7
|
|
|
8
8
|
- `invalid value for argument <name>`
|
|
9
9
|
- `invalid value of <name>`
|
|
10
|
+
- `input does not match ...`
|
|
10
11
|
|
|
11
12
|
## Fix
|
|
12
13
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# `transport/cancel-unacknowledged`
|
|
2
|
+
|
|
3
|
+
RouterOS did not acknowledge a stream's /cancel within the timeout; centrs ended
|
|
4
|
+
the stream and closed the session locally.
|
|
5
|
+
|
|
6
|
+
## When it happens
|
|
7
|
+
|
|
8
|
+
This is a **warning** on the summary envelope of `centrs api … --stream`, not a
|
|
9
|
+
failure. The stream stopped for its normal reason (`--duration` elapsed, or
|
|
10
|
+
Ctrl-C), and centrs sent RouterOS `/cancel` for the `/listen`. A cooperating
|
|
11
|
+
router answers with an `interrupted` trap and `!done` within milliseconds. This
|
|
12
|
+
one did not answer within `--timeout`, so centrs stopped waiting, closed the
|
|
13
|
+
native API session and finished.
|
|
14
|
+
|
|
15
|
+
The summary's `stopReason` is still the reason the stream stopped. The warning
|
|
16
|
+
records that the router never confirmed the cancel, so the stop was local.
|
|
17
|
+
|
|
18
|
+
## Fix
|
|
19
|
+
|
|
20
|
+
- Usually nothing: the stream ended when you asked, and the session is closed.
|
|
21
|
+
- If it repeats against one router, the API connection is likely stalled (a
|
|
22
|
+
router under load, or a firewall or NAT device holding the TCP session).
|
|
23
|
+
Check the router's CPU and the path to it before relying on long-lived
|
|
24
|
+
streams there.
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The structured middle of the verb trichotomy (see `commands/api/README.md`):
|
|
5
5
|
* one command per operation (no code blocks), structured input **and** output,
|
|
6
|
-
* validated through `/console/inspect
|
|
6
|
+
* validated through `/console/inspect` (or live `:parse` for `/execute` scripts),
|
|
7
|
+
* runnable over REST or the native API. The
|
|
7
8
|
* orchestrator is transport-agnostic — it normalizes the endpoint, resolves the
|
|
8
|
-
* method→verb map, runs the
|
|
9
|
+
* method→verb map, runs the validation and write-confirmation gates, then
|
|
9
10
|
* hands a normalized {@link ProtocolApiRequest} to the adapter. REST vs native
|
|
10
11
|
* mechanics (id-in-URL vs `=.id=`, `.query` vs `?`-words) live in the adapter.
|
|
11
12
|
*
|
|
@@ -33,9 +34,13 @@ import {
|
|
|
33
34
|
isCommandNode,
|
|
34
35
|
pathTokens,
|
|
35
36
|
} from "./core/inspect.ts";
|
|
37
|
+
import { mapRouterOsResultError } from "./core/routeros-errors.ts";
|
|
36
38
|
import { toYaml } from "./core/yaml.ts";
|
|
37
39
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
promptForWriteConfirmation,
|
|
42
|
+
validateRouterOsScript,
|
|
43
|
+
} from "./execute.ts";
|
|
39
44
|
import {
|
|
40
45
|
assertOfflineSyntax,
|
|
41
46
|
isOfflineGateRejection,
|
|
@@ -46,6 +51,7 @@ import {
|
|
|
46
51
|
createProtocolAdapter,
|
|
47
52
|
type ProtocolAdapter,
|
|
48
53
|
type ProtocolApiRequest,
|
|
54
|
+
type ProtocolApiResult,
|
|
49
55
|
plannedProtocols,
|
|
50
56
|
type RouterOsProtocol,
|
|
51
57
|
} from "./protocols/index.ts";
|
|
@@ -293,6 +299,11 @@ export async function runResolvedApi(
|
|
|
293
299
|
trace: ValidationTrace = {},
|
|
294
300
|
): Promise<ApiSuccessEnvelope> {
|
|
295
301
|
assertListenCapability(resolved);
|
|
302
|
+
// Validate the complete request shape before constructing a backend or
|
|
303
|
+
// running either validation stage. In particular, `/execute` must reject
|
|
304
|
+
// extra body fields deterministically instead of letting live `:parse`
|
|
305
|
+
// surface an unrelated transport or authentication failure first.
|
|
306
|
+
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
296
307
|
const backend = createProtocolAdapter({
|
|
297
308
|
protocol: resolved.via.value,
|
|
298
309
|
host: resolved.target.host,
|
|
@@ -320,7 +331,11 @@ export async function runResolvedApi(
|
|
|
320
331
|
// reports the gate that actually settled rather than a guess.
|
|
321
332
|
trace.validation = validation;
|
|
322
333
|
|
|
323
|
-
const result = await backend.apiRequest(
|
|
334
|
+
const result = await backend.apiRequest(protocolRequest);
|
|
335
|
+
const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
|
|
336
|
+
if (routerOsFailure) {
|
|
337
|
+
throw routerOsFailure;
|
|
338
|
+
}
|
|
324
339
|
return {
|
|
325
340
|
ok: true,
|
|
326
341
|
data: result.data,
|
|
@@ -333,6 +348,35 @@ export async function runResolvedApi(
|
|
|
333
348
|
}
|
|
334
349
|
}
|
|
335
350
|
|
|
351
|
+
/**
|
|
352
|
+
* A `/execute` run that RouterOS *transported* successfully but *rejected* at
|
|
353
|
+
* runtime.
|
|
354
|
+
*
|
|
355
|
+
* `as-string` makes the console's own text the reply body, so a rejected
|
|
356
|
+
* script comes back as an ordinary 200/`!done` carrying `no such item`. Both
|
|
357
|
+
* adapters surface that text as `data`, so without this the caller-addressed
|
|
358
|
+
* `/execute` would report a failure as `ok: true` — exactly the shape
|
|
359
|
+
* `execute` normalizes in `routerOsFailureFromResult`. Structured path
|
|
360
|
+
* requests are untouched: their faults already arrive as HTTP >=400 or a
|
|
361
|
+
* `!trap`, and their `data` is a record, not console text.
|
|
362
|
+
*/
|
|
363
|
+
function apiRouterOsFailureFromResult(
|
|
364
|
+
resolved: ResolvedApiRequest,
|
|
365
|
+
result: ProtocolApiResult,
|
|
366
|
+
): CentrsError | undefined {
|
|
367
|
+
if (!resolved.scriptMode || typeof result.data !== "string") {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
const via = resolved.via.value;
|
|
371
|
+
return mapRouterOsResultError(result.data, {
|
|
372
|
+
transport:
|
|
373
|
+
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
374
|
+
? via
|
|
375
|
+
: "native-api",
|
|
376
|
+
context: { via },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
336
380
|
/**
|
|
337
381
|
* The open-ended `--stream` follow path: an async generator yielding one
|
|
338
382
|
* {@link ApiEnvelope} per `/listen` change frame, then a terminating summary
|
|
@@ -399,14 +443,18 @@ async function* streamResolvedApi(
|
|
|
399
443
|
const startedAt = Date.now();
|
|
400
444
|
let frames = 0;
|
|
401
445
|
let stopReason: ApiStreamStopReason | undefined;
|
|
446
|
+
let cancelUnacknowledged = false;
|
|
402
447
|
let durationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
448
|
+
// The first stop cause wins: the cancel grace window keeps the loop alive
|
|
449
|
+
// after a stop, so a later timer or frame must not relabel it.
|
|
403
450
|
const onExternalAbort = (): void => {
|
|
404
451
|
stopReason ??= "interrupted";
|
|
452
|
+
clearTimeout(durationTimer);
|
|
405
453
|
controller.abort();
|
|
406
454
|
};
|
|
407
455
|
if (resolved.durationMs !== undefined) {
|
|
408
456
|
durationTimer = setTimeout(() => {
|
|
409
|
-
stopReason
|
|
457
|
+
stopReason ??= "duration-elapsed";
|
|
410
458
|
controller.abort();
|
|
411
459
|
}, resolved.durationMs);
|
|
412
460
|
}
|
|
@@ -423,11 +471,14 @@ async function* streamResolvedApi(
|
|
|
423
471
|
for await (const record of backend.listen(protocolRequest, {
|
|
424
472
|
signal: controller.signal,
|
|
425
473
|
onListening,
|
|
474
|
+
onCancelUnacknowledged: () => {
|
|
475
|
+
cancelUnacknowledged = true;
|
|
476
|
+
},
|
|
426
477
|
})) {
|
|
427
478
|
frames += 1;
|
|
428
479
|
yield streamFrameEnvelope(resolved, validation, record, frames);
|
|
429
480
|
if (resolved.count !== undefined && frames >= resolved.count) {
|
|
430
|
-
stopReason
|
|
481
|
+
stopReason ??= "count-reached";
|
|
431
482
|
break;
|
|
432
483
|
}
|
|
433
484
|
}
|
|
@@ -437,6 +488,15 @@ async function* streamResolvedApi(
|
|
|
437
488
|
stopReason ?? "interrupted",
|
|
438
489
|
frames,
|
|
439
490
|
Date.now() - startedAt,
|
|
491
|
+
cancelUnacknowledged
|
|
492
|
+
? [
|
|
493
|
+
{
|
|
494
|
+
code: "transport/cancel-unacknowledged",
|
|
495
|
+
message: `RouterOS did not acknowledge /cancel within ${resolved.timeoutMs.value}ms; centrs closed the session locally.`,
|
|
496
|
+
context: { timeoutMs: resolved.timeoutMs.value },
|
|
497
|
+
},
|
|
498
|
+
]
|
|
499
|
+
: [],
|
|
440
500
|
);
|
|
441
501
|
} catch (error) {
|
|
442
502
|
// A mid-stream failure is itself a frame, tagged `stream.kind="frame"` so
|
|
@@ -496,6 +556,7 @@ function streamSummaryEnvelope(
|
|
|
496
556
|
stopReason: ApiStreamStopReason,
|
|
497
557
|
frames: number,
|
|
498
558
|
durationMs: number,
|
|
559
|
+
streamWarnings: readonly Warning[] = [],
|
|
499
560
|
): ApiSuccessEnvelope {
|
|
500
561
|
const summary: ApiStreamSummary = { stopReason, frames, durationMs };
|
|
501
562
|
const meta = metaFromResolved(resolved, validation, summary);
|
|
@@ -504,7 +565,7 @@ function streamSummaryEnvelope(
|
|
|
504
565
|
return {
|
|
505
566
|
ok: true,
|
|
506
567
|
data: summary,
|
|
507
|
-
warnings: [...resolved.warnings],
|
|
568
|
+
warnings: [...resolved.warnings, ...streamWarnings],
|
|
508
569
|
tips: [],
|
|
509
570
|
meta,
|
|
510
571
|
};
|
|
@@ -955,8 +1016,9 @@ export function buildProtocolApiRequest(
|
|
|
955
1016
|
return request;
|
|
956
1017
|
}
|
|
957
1018
|
|
|
958
|
-
/** The api device-stage validator
|
|
1019
|
+
/** The api device-stage validator for structured path input. */
|
|
959
1020
|
const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
|
|
1021
|
+
const API_SCRIPT_DEVICE_STAGE_SOURCE = ":put [:parse]";
|
|
960
1022
|
|
|
961
1023
|
interface ApiValidationResult {
|
|
962
1024
|
validation: EnvelopeValidationMeta;
|
|
@@ -976,11 +1038,9 @@ async function validateApiRequest(
|
|
|
976
1038
|
trace: ValidationTrace = {},
|
|
977
1039
|
): Promise<ApiValidationResult> {
|
|
978
1040
|
if (resolved.scriptMode) {
|
|
979
|
-
// `/execute` carries a CLI string, so
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
// The offline analyzer is the whole gate here: a syntax fault is now a
|
|
983
|
-
// byte span and no round trip, where before it reached the device.
|
|
1041
|
+
// `/execute` carries a CLI string, so use the same offline -> live `:parse`
|
|
1042
|
+
// pipeline as `execute`. `/console/inspect` remains inapplicable because
|
|
1043
|
+
// there is no structured menu path/body to inspect.
|
|
984
1044
|
const script = scriptOf(resolved);
|
|
985
1045
|
const offline = script
|
|
986
1046
|
? assertOfflineSyntax(script, {
|
|
@@ -989,6 +1049,9 @@ async function validateApiRequest(
|
|
|
989
1049
|
})
|
|
990
1050
|
: undefined;
|
|
991
1051
|
trace.offlineVerdict = offline?.verdict;
|
|
1052
|
+
if (script !== undefined) {
|
|
1053
|
+
await validateRouterOsScript(script, backend, resolved.via.value);
|
|
1054
|
+
}
|
|
992
1055
|
const offlineReason =
|
|
993
1056
|
offline === undefined
|
|
994
1057
|
? "no `script` field to analyze"
|
|
@@ -998,9 +1061,12 @@ async function validateApiRequest(
|
|
|
998
1061
|
return {
|
|
999
1062
|
validation: {
|
|
1000
1063
|
enabled: true,
|
|
1001
|
-
source:
|
|
1064
|
+
source:
|
|
1065
|
+
offline === undefined
|
|
1066
|
+
? "not-applicable"
|
|
1067
|
+
: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
1002
1068
|
result: "passed",
|
|
1003
|
-
syntax:
|
|
1069
|
+
syntax: script !== undefined,
|
|
1004
1070
|
semantic: "not-applicable",
|
|
1005
1071
|
stages: [
|
|
1006
1072
|
{
|
|
@@ -1011,10 +1077,11 @@ async function validateApiRequest(
|
|
|
1011
1077
|
},
|
|
1012
1078
|
{
|
|
1013
1079
|
stage: "device",
|
|
1014
|
-
source:
|
|
1015
|
-
result: "skipped",
|
|
1016
|
-
|
|
1017
|
-
|
|
1080
|
+
source: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
1081
|
+
result: script === undefined ? "skipped" : "passed",
|
|
1082
|
+
...(script === undefined
|
|
1083
|
+
? { reason: "no `script` field to parse" }
|
|
1084
|
+
: {}),
|
|
1018
1085
|
},
|
|
1019
1086
|
],
|
|
1020
1087
|
},
|
|
@@ -1441,7 +1508,7 @@ function failedApiValidationMeta(
|
|
|
1441
1508
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
1442
1509
|
{
|
|
1443
1510
|
stage: "device",
|
|
1444
|
-
source:
|
|
1511
|
+
source: apiDeviceStageSource(resolved),
|
|
1445
1512
|
result: "skipped",
|
|
1446
1513
|
reason: "offline analysis rejected the script; no connection opened",
|
|
1447
1514
|
},
|
|
@@ -1455,12 +1522,17 @@ function failedApiValidationMeta(
|
|
|
1455
1522
|
const deviceRejected = error.code.startsWith("validation/");
|
|
1456
1523
|
return {
|
|
1457
1524
|
enabled: true,
|
|
1458
|
-
source:
|
|
1525
|
+
source: apiDeviceStageSource(resolved),
|
|
1459
1526
|
result: "failed",
|
|
1460
1527
|
syntax: false,
|
|
1461
1528
|
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1462
1529
|
...(deviceRejected
|
|
1463
|
-
? {
|
|
1530
|
+
? {
|
|
1531
|
+
stages: [
|
|
1532
|
+
offlineApiStage(resolved, trace),
|
|
1533
|
+
deviceStageFailed(resolved),
|
|
1534
|
+
],
|
|
1535
|
+
}
|
|
1464
1536
|
: {}),
|
|
1465
1537
|
};
|
|
1466
1538
|
}
|
|
@@ -1494,10 +1566,18 @@ function offlineApiStage(
|
|
|
1494
1566
|
};
|
|
1495
1567
|
}
|
|
1496
1568
|
|
|
1497
|
-
function
|
|
1569
|
+
function apiDeviceStageSource(resolved: ResolvedApiRequest): string {
|
|
1570
|
+
return resolved.scriptMode
|
|
1571
|
+
? API_SCRIPT_DEVICE_STAGE_SOURCE
|
|
1572
|
+
: API_DEVICE_STAGE_SOURCE;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function deviceStageFailed(
|
|
1576
|
+
resolved: ResolvedApiRequest,
|
|
1577
|
+
): EnvelopeValidationStage {
|
|
1498
1578
|
return {
|
|
1499
1579
|
stage: "device",
|
|
1500
|
-
source:
|
|
1580
|
+
source: apiDeviceStageSource(resolved),
|
|
1501
1581
|
result: "failed",
|
|
1502
1582
|
};
|
|
1503
1583
|
}
|
|
@@ -1518,7 +1598,11 @@ function disabledApiValidationMeta(
|
|
|
1518
1598
|
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1519
1599
|
stages: [
|
|
1520
1600
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
1521
|
-
{
|
|
1601
|
+
{
|
|
1602
|
+
stage: "device",
|
|
1603
|
+
source: apiDeviceStageSource(resolved),
|
|
1604
|
+
result: "skipped",
|
|
1605
|
+
},
|
|
1522
1606
|
],
|
|
1523
1607
|
};
|
|
1524
1608
|
}
|
package/src/cli/devices.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
describeCentrs,
|
|
23
23
|
devicesOutputFormats,
|
|
24
24
|
editInteractiveOnlyError,
|
|
25
|
+
initCdb,
|
|
25
26
|
listDevices,
|
|
26
27
|
listGroups,
|
|
27
28
|
loadCdb,
|
|
@@ -46,7 +47,8 @@ import {
|
|
|
46
47
|
|
|
47
48
|
export const devicesCommand: CliCommandMetadata = {
|
|
48
49
|
name: "devices",
|
|
49
|
-
usage:
|
|
50
|
+
usage:
|
|
51
|
+
"centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
50
52
|
summary:
|
|
51
53
|
"Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
|
|
52
54
|
options: [
|
|
@@ -197,6 +199,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
197
199
|
};
|
|
198
200
|
|
|
199
201
|
type DevicesSubcommand =
|
|
202
|
+
| "init"
|
|
200
203
|
| "list"
|
|
201
204
|
| "show"
|
|
202
205
|
| "groups"
|
|
@@ -378,6 +381,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
378
381
|
}
|
|
379
382
|
|
|
380
383
|
const subcommands: readonly DevicesSubcommand[] = [
|
|
384
|
+
"init",
|
|
381
385
|
"list",
|
|
382
386
|
"show",
|
|
383
387
|
"groups",
|
|
@@ -389,14 +393,14 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
389
393
|
const [rawSub, ...rest] = positional;
|
|
390
394
|
if (rawSub === undefined) {
|
|
391
395
|
throw new Error(
|
|
392
|
-
"`centrs devices` requires a subcommand: list, show, groups, add, edit, set, or remove.",
|
|
396
|
+
"`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
|
|
393
397
|
);
|
|
394
398
|
}
|
|
395
399
|
// Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
|
|
396
400
|
const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
|
|
397
401
|
if (!subcommands.includes(sub)) {
|
|
398
402
|
throw new Error(
|
|
399
|
-
`Unknown devices subcommand: ${rawSub}. Use list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
403
|
+
`Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
400
404
|
);
|
|
401
405
|
}
|
|
402
406
|
parsed.subcommand = sub;
|
|
@@ -606,6 +610,15 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
envSnapshot = Bun.env;
|
|
613
|
+
if (parsed.subcommand === "init") {
|
|
614
|
+
const envelope = await initCdb({
|
|
615
|
+
cdbFile: parsed.cdbFile,
|
|
616
|
+
cdbPassword: parsed.cdbPassword,
|
|
617
|
+
env: envSnapshot,
|
|
618
|
+
});
|
|
619
|
+
console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
609
622
|
const cdb = await loadCdb({
|
|
610
623
|
cdbFile: parsed.cdbFile,
|
|
611
624
|
cdbPassword: parsed.cdbPassword,
|
|
@@ -40,6 +40,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
40
40
|
code: "cdb/already-exists",
|
|
41
41
|
summary: "A CDB entry with that target already exists.",
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
code: "cdb/create-failed",
|
|
45
|
+
summary: "A new CDB file could not be created at the resolved location.",
|
|
46
|
+
},
|
|
43
47
|
{
|
|
44
48
|
code: "cdb/created",
|
|
45
49
|
summary: "A new CDB file was created at the resolved location.",
|
|
@@ -57,6 +61,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
57
61
|
code: "cdb/empty-selection",
|
|
58
62
|
summary: "The target selection matched no CDB records.",
|
|
59
63
|
},
|
|
64
|
+
{
|
|
65
|
+
code: "cdb/file-exists",
|
|
66
|
+
summary:
|
|
67
|
+
"`devices init` found a CDB already at the resolved location and wrote nothing.",
|
|
68
|
+
},
|
|
60
69
|
{
|
|
61
70
|
code: "cdb/invalid-option",
|
|
62
71
|
summary: "A comment kv-soup option carried an invalid value.",
|
|
@@ -430,6 +439,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
430
439
|
summary:
|
|
431
440
|
"Auto-selection moved the operation to a different method (informational auto-hop).",
|
|
432
441
|
},
|
|
442
|
+
{
|
|
443
|
+
code: "transport/cancel-unacknowledged",
|
|
444
|
+
summary:
|
|
445
|
+
"RouterOS did not acknowledge a stream's /cancel within the timeout; centrs ended the stream and closed the session locally.",
|
|
446
|
+
},
|
|
433
447
|
{
|
|
434
448
|
code: "transport/capability-unsupported",
|
|
435
449
|
summary: "The chosen transport cannot perform this kind of operation.",
|