@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +8 -4
  3. package/commands/api/README.md +21 -10
  4. package/commands/api/examples.md +70 -2
  5. package/commands/devices/README.md +10 -1
  6. package/commands/devices/examples.md +33 -2
  7. package/commands/execute/README.md +29 -15
  8. package/commands/execute/examples.md +89 -0
  9. package/commands/explain/README.md +12 -1
  10. package/commands/mcp/README.md +2 -1
  11. package/commands/retrieve/examples.md +20 -0
  12. package/docs/CLI.md +1 -1
  13. package/docs/CONSTITUTION.md +11 -6
  14. package/docs/errors/cdb/create-failed.md +20 -0
  15. package/docs/errors/cdb/file-exists.md +23 -0
  16. package/docs/errors/input/invalid-target.md +25 -3
  17. package/docs/errors/routeros/invalid-value.md +3 -2
  18. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  19. package/package.json +1 -1
  20. package/src/api.ts +109 -25
  21. package/src/cli/devices.ts +16 -3
  22. package/src/core/error-catalog.ts +14 -0
  23. package/src/core/execute-command.ts +313 -6
  24. package/src/core/inspect.ts +22 -22
  25. package/src/core/routeros-errors.ts +66 -0
  26. package/src/data/winbox-cdb-write.ts +22 -8
  27. package/src/devices.ts +142 -28
  28. package/src/execute-fanout.ts +1 -1
  29. package/src/execute.ts +22 -51
  30. package/src/explain/catalog.ts +8 -8
  31. package/src/explain/menus.ts +4 -4
  32. package/src/index.ts +2 -0
  33. package/src/mcp/tools.ts +1 -1
  34. package/src/protocols/adapter.ts +12 -1
  35. package/src/protocols/mac-telnet-console.ts +6 -4
  36. package/src/protocols/native-api.ts +29 -3
  37. package/src/resolver/target.ts +32 -12
  38. package/src/retrieve.ts +17 -4
@@ -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`) / `syntax error
88
- (line N column M)`. That text rides the **HTTP-200 `ret` value** (REST,
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 which validation runs and whether the write-confirmation prompt
539
- fires; widening what counts as `structured` is a product regression. The shared,
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` or `writeShaped`
550
- decision.
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
- See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
8
- contract. This stub will be expanded with the typical trigger and remediation
9
- for `input/invalid-target`.
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 the value supplied for an argument. The offending argument
4
- name is captured in `context.argument`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
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`, runnable over REST or the native API. The
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 inspect gate and the write-confirmation gate, then
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 { promptForWriteConfirmation } from "./execute.ts";
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(buildProtocolApiRequest(resolved));
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 = "duration-elapsed";
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 = "count-reached";
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: structured input is inspected, never `:parse`d. */
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 `/console/inspect` has nothing to
980
- // inspect — which until GH#354 meant the script was sent with NO gate at
981
- // all (`syntax: false`, `semantic: "not-applicable"`, `result: "passed"`).
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: offline === undefined ? "not-applicable" : OFFLINE_GATE_SOURCE,
1064
+ source:
1065
+ offline === undefined
1066
+ ? "not-applicable"
1067
+ : API_SCRIPT_DEVICE_STAGE_SOURCE,
1002
1068
  result: "passed",
1003
- syntax: offline !== undefined,
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: "/console/inspect request=child",
1015
- result: "skipped",
1016
- reason:
1017
- "`/execute` is a CLI string, not a menu path; RouterOS re-validates it on the run",
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: API_DEVICE_STAGE_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: "/console/inspect",
1525
+ source: apiDeviceStageSource(resolved),
1459
1526
  result: "failed",
1460
1527
  syntax: false,
1461
1528
  semantic: resolved.scriptMode ? "not-applicable" : false,
1462
1529
  ...(deviceRejected
1463
- ? { stages: [offlineApiStage(resolved, trace), deviceStageFailed()] }
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 deviceStageFailed(): EnvelopeValidationStage {
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: API_DEVICE_STAGE_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
- { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "skipped" },
1601
+ {
1602
+ stage: "device",
1603
+ source: apiDeviceStageSource(resolved),
1604
+ result: "skipped",
1605
+ },
1522
1606
  ],
1523
1607
  };
1524
1608
  }
@@ -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: "centrs devices <list|show|groups|add|edit|set|remove> [args] [flags]",
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.",