@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.27

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 (91) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/GLOSSARY.txt +23 -1
  3. package/README.md +30 -18
  4. package/commands/api/AGENTS.md +21 -3
  5. package/commands/api/README.md +18 -7
  6. package/commands/api/examples.md +1 -1
  7. package/commands/btest/README.md +9 -0
  8. package/commands/btest/examples.md +7 -0
  9. package/commands/devices/README.md +72 -3
  10. package/commands/devices/examples.md +56 -1
  11. package/commands/execute/README.md +37 -6
  12. package/commands/execute/examples.md +24 -5
  13. package/commands/explain/README.md +675 -64
  14. package/commands/explain/examples.md +199 -42
  15. package/commands/retrieve/README.md +35 -3
  16. package/commands/retrieve/examples.md +114 -1
  17. package/commands/transfer/README.md +33 -4
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +30 -8
  20. package/docs/CONSTITUTION.md +14 -9
  21. package/docs/MATRIX.md +37 -22
  22. package/docs/errors/cdb/encrypt-failed.md +10 -0
  23. package/docs/errors/cdb/password-required.md +5 -4
  24. package/docs/errors/cdb/plaintext-backups.md +11 -0
  25. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  26. package/docs/errors/input/invalid-routeros-path.md +25 -3
  27. package/docs/errors/routeros/permission-denied.md +24 -0
  28. package/docs/errors/transport/edge-auth-failed.md +22 -0
  29. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  30. package/docs/errors/usage/invalid-cursor.md +20 -0
  31. package/docs/errors/validation/unknown-path.md +9 -0
  32. package/package.json +9 -5
  33. package/src/api-fanout.ts +2 -0
  34. package/src/api.ts +46 -39
  35. package/src/btest.ts +7 -0
  36. package/src/cli/api.ts +18 -1
  37. package/src/cli/btest.ts +9 -0
  38. package/src/cli/devices.ts +76 -8
  39. package/src/cli/execute.ts +9 -0
  40. package/src/cli/explain.ts +206 -47
  41. package/src/cli/retrieve.ts +9 -0
  42. package/src/cli/terminal.ts +9 -0
  43. package/src/cli/transfer.ts +10 -1
  44. package/src/core/envelope.ts +10 -3
  45. package/src/core/error-catalog.ts +28 -2
  46. package/src/core/execute-command.ts +53 -0
  47. package/src/core/fanout.ts +12 -2
  48. package/src/core/inspect.ts +4 -5
  49. package/src/core/menu-path.ts +89 -0
  50. package/src/core/routeros-errors.ts +25 -0
  51. package/src/devices.ts +154 -9
  52. package/src/execute-cmr.ts +2 -8
  53. package/src/execute-fanout.ts +2 -0
  54. package/src/execute.ts +55 -20
  55. package/src/explain/args.ts +115 -10
  56. package/src/explain/blocks.ts +2 -2
  57. package/src/explain/catalog.ts +22 -12
  58. package/src/explain/group-faults.ts +162 -29
  59. package/src/explain/operators.ts +2 -1
  60. package/src/explain/pasted-prompt.ts +198 -0
  61. package/src/explain/pathresolve.ts +2 -2
  62. package/src/explain/scope-brace.ts +226 -11
  63. package/src/explain/segment.ts +138 -3
  64. package/src/explain/symbols.ts +148 -8
  65. package/src/explain/values.ts +55 -4
  66. package/src/explain/verbsplit.ts +2 -2
  67. package/src/explain/write.ts +2 -2
  68. package/src/explain-live-facets.ts +739 -0
  69. package/src/explain-live-types.ts +490 -0
  70. package/src/explain-live.ts +593 -0
  71. package/src/explain.ts +397 -43
  72. package/src/index.ts +10 -0
  73. package/src/protocols/adapter.ts +90 -10
  74. package/src/protocols/btest-session.ts +10 -30
  75. package/src/protocols/ec-srp5.ts +19 -10
  76. package/src/protocols/mac-telnet-console.ts +2 -2
  77. package/src/protocols/mac-telnet.ts +38 -10
  78. package/src/protocols/native-api.ts +13 -12
  79. package/src/resolver/cdb.ts +58 -2
  80. package/src/resolver/index.ts +1 -0
  81. package/src/resolver/quickchr-provider.ts +5 -0
  82. package/src/resolver/selection.ts +11 -0
  83. package/src/retrieve-fanout.ts +2 -0
  84. package/src/retrieve-follow.ts +2 -1
  85. package/src/retrieve-sample.ts +2 -1
  86. package/src/retrieve-wait.ts +10 -6
  87. package/src/retrieve.ts +88 -27
  88. package/src/settings.ts +1 -0
  89. package/src/terminal.ts +7 -0
  90. package/src/transfer-fanout.ts +2 -0
  91. package/src/transfer.ts +117 -27
package/src/api.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  isCommandNode,
46
46
  pathTokens,
47
47
  } from "./core/inspect.ts";
48
+ import { assertRouterOsMenuPath } from "./core/menu-path.ts";
48
49
  import {
49
50
  compileQueryWords,
50
51
  type ParsedQuery,
@@ -62,6 +63,7 @@ import {
62
63
  promptForWriteConfirmation,
63
64
  validateRouterOsScript,
64
65
  } from "./execute.ts";
66
+ import { countExecutableStatements } from "./explain/segment.ts";
65
67
  import {
66
68
  assertOfflineSyntax,
67
69
  isOfflineGateRejection,
@@ -159,6 +161,12 @@ export interface ApiRequest {
159
161
  verbose?: boolean;
160
162
  cdbFile?: string;
161
163
  cdbPassword?: string;
164
+ /**
165
+ * `--match` selector for a `<router>` several CDB records share
166
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
167
+ * `devices show --match` takes. Single-target only (#583).
168
+ */
169
+ match?: string;
162
170
  /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
163
171
  resolve?: string;
164
172
  stdinIsTty?: boolean;
@@ -541,7 +549,7 @@ async function* streamResolvedApi(
541
549
  trace.validation = validation;
542
550
  resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
543
551
  } else {
544
- validation = disabledValidationMeta();
552
+ validation = disabledApiValidationMeta(resolved);
545
553
  }
546
554
  } catch (error) {
547
555
  await backend.close();
@@ -824,16 +832,6 @@ function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
824
832
  });
825
833
  }
826
834
 
827
- function disabledValidationMeta(): EnvelopeValidationMeta {
828
- return {
829
- enabled: false,
830
- source: "disabled",
831
- result: "skipped",
832
- syntax: false,
833
- semantic: false,
834
- };
835
- }
836
-
837
835
  export async function resolveApiRequest(
838
836
  request: ApiRequest,
839
837
  env: Record<string, string | undefined>,
@@ -946,6 +944,7 @@ export async function resolveApiRequest(
946
944
  targetInput: request.targetInput,
947
945
  cdbFile: request.cdbFile,
948
946
  cdbPassword: request.cdbPassword,
947
+ match: request.match,
949
948
  },
950
949
  env,
951
950
  config,
@@ -1347,7 +1346,8 @@ interface ApiValidationResult {
1347
1346
  * validator is `/console/inspect`, never `:put [:parse]`. Path existence and
1348
1347
  * add/set attribute validity both come from `request=child` — command-level
1349
1348
  * `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
1350
- * `/execute` script is a CLI string → `semantic: not-applicable`.
1349
+ * `/execute` script is a CLI string, so it has no `semantic` summary; a
1350
+ * structured request runs no `:parse`, so it has no `syntax` summary (#535).
1351
1351
  */
1352
1352
  async function validateApiRequest(
1353
1353
  resolved: ResolvedApiRequest,
@@ -1383,8 +1383,7 @@ async function validateApiRequest(
1383
1383
  ? "not-applicable"
1384
1384
  : API_SCRIPT_DEVICE_STAGE_SOURCE,
1385
1385
  result: "passed",
1386
- syntax: script !== undefined,
1387
- semantic: "not-applicable",
1386
+ ...(script !== undefined ? { syntax: true } : {}),
1388
1387
  stages: [
1389
1388
  {
1390
1389
  stage: "offline",
@@ -1468,13 +1467,7 @@ async function validateApiRequest(
1468
1467
  }
1469
1468
  }
1470
1469
  return {
1471
- validation: {
1472
- enabled: true,
1473
- source: "/console/inspect request=child",
1474
- result: "passed",
1475
- syntax: false,
1476
- semantic: true,
1477
- },
1470
+ validation: structuredValidationPassed(resolved),
1478
1471
  tips,
1479
1472
  queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
1480
1473
  };
@@ -1513,11 +1506,7 @@ async function validateApiRequest(
1513
1506
  }
1514
1507
  return {
1515
1508
  validation: {
1516
- enabled: true,
1517
- source: "/console/inspect request=child",
1518
- result: "passed",
1519
- syntax: false,
1520
- semantic: true,
1509
+ ...structuredValidationPassed(resolved),
1521
1510
  availableAttributes: available,
1522
1511
  },
1523
1512
  tips,
@@ -1526,13 +1515,7 @@ async function validateApiRequest(
1526
1515
  }
1527
1516
 
1528
1517
  return {
1529
- validation: {
1530
- enabled: true,
1531
- source: "/console/inspect request=child",
1532
- result: "passed",
1533
- syntax: false,
1534
- semantic: true,
1535
- },
1518
+ validation: structuredValidationPassed(resolved),
1536
1519
  tips,
1537
1520
  queryMenu: resolved.path,
1538
1521
  };
@@ -1857,6 +1840,12 @@ export function buildApiErrorEnvelopeFromResolved(
1857
1840
  trace?: ValidationTrace,
1858
1841
  ): ApiErrorEnvelope {
1859
1842
  const centrsError = asApiError(error);
1843
+ // A structured request is one operation; only a `/execute` script can
1844
+ // carry several statements into one dispatch (#549), at the top level or
1845
+ // inside an executed control scope.
1846
+ const script = resolved.scriptMode ? scriptOf(resolved) : undefined;
1847
+ const multiStatement =
1848
+ script !== undefined && countExecutableStatements(script) > 1;
1860
1849
  return {
1861
1850
  ok: false,
1862
1851
  error: serializeCentrsError(centrsError),
@@ -1866,6 +1855,7 @@ export function buildApiErrorEnvelopeFromResolved(
1866
1855
  isApiMutating(resolved.method, resolved.path),
1867
1856
  centrsError,
1868
1857
  trace,
1858
+ multiStatement,
1869
1859
  ),
1870
1860
  ],
1871
1861
  tips: [],
@@ -1877,6 +1867,22 @@ export function buildApiErrorEnvelopeFromResolved(
1877
1867
  };
1878
1868
  }
1879
1869
 
1870
+ /** A structured request the device stage passed; stage 1 does not apply to it. */
1871
+ function structuredValidationPassed(
1872
+ resolved: ResolvedApiRequest,
1873
+ ): EnvelopeValidationMeta {
1874
+ return {
1875
+ enabled: true,
1876
+ source: API_DEVICE_STAGE_SOURCE,
1877
+ result: "passed",
1878
+ semantic: true,
1879
+ stages: [
1880
+ offlineApiStage(resolved),
1881
+ { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "passed" },
1882
+ ],
1883
+ };
1884
+ }
1885
+
1880
1886
  /**
1881
1887
  * `meta.validation` for an api error envelope. A stage-1 rejection opened no
1882
1888
  * connection, so the meta must not report `/console/inspect` as the validator
@@ -1896,7 +1902,6 @@ function failedApiValidationMeta(
1896
1902
  source: OFFLINE_GATE_SOURCE,
1897
1903
  result: "failed",
1898
1904
  syntax: false,
1899
- semantic: "not-applicable",
1900
1905
  stages: [
1901
1906
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
1902
1907
  {
@@ -1911,14 +1916,14 @@ function failedApiValidationMeta(
1911
1916
  // Only a `validation/*` error identifies the device stage as the rejecter.
1912
1917
  // Everything downstream of a passed gate — a RouterOS fault on the request,
1913
1918
  // a transport drop — is served by `trace.validation` before this function is
1914
- // ever reached.
1919
+ // ever reached. A script is refused by `:parse` (syntax); a structured
1920
+ // request by `/console/inspect` (names) (#535).
1915
1921
  if (error.code.startsWith("validation/")) {
1916
1922
  return {
1917
1923
  enabled: true,
1918
1924
  source: apiDeviceStageSource(resolved),
1919
1925
  result: "failed",
1920
- syntax: false,
1921
- semantic: resolved.scriptMode ? "not-applicable" : false,
1926
+ ...(resolved.scriptMode ? { syntax: false } : { semantic: false }),
1922
1927
  stages: [offlineApiStage(resolved, trace), deviceStageFailed(resolved)],
1923
1928
  };
1924
1929
  }
@@ -2012,8 +2017,6 @@ function disabledApiValidationMeta(
2012
2017
  enabled: false,
2013
2018
  source: "disabled",
2014
2019
  result: "skipped",
2015
- syntax: false,
2016
- semantic: resolved.scriptMode ? "not-applicable" : false,
2017
2020
  stages: [
2018
2021
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
2019
2022
  {
@@ -2181,6 +2184,10 @@ export function validateApiRequestShape(request: ApiRequest): void {
2181
2184
  "Pass a path with at least one menu segment, such as `ip/address`.",
2182
2185
  });
2183
2186
  }
2187
+ // Before transport choice and regardless of --validate: a `#`/`?` in the
2188
+ // path cut `/print` off the REST URL and a GET read posted to the bare
2189
+ // command, rebooting the router (#586).
2190
+ assertRouterOsMenuPath(normalized.path, { endpoint: request.endpoint });
2184
2191
  }
2185
2192
 
2186
2193
  /**
package/src/btest.ts CHANGED
@@ -113,6 +113,12 @@ export interface BtestClientRequest {
113
113
  natMode?: boolean;
114
114
  cdbFile?: string;
115
115
  cdbPassword?: string;
116
+ /**
117
+ * `--match` selector for a `<router>` several CDB records share
118
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
119
+ * `devices show --match` takes. Single-target only (#583).
120
+ */
121
+ match?: string;
116
122
  env?: Record<string, string | undefined>;
117
123
  signal?: AbortSignal;
118
124
  /** Live per-interval frame callback (text/csv streaming). */
@@ -264,6 +270,7 @@ export async function btestClient(
264
270
  targetInput: request.targetInput,
265
271
  cdbFile: request.cdbFile,
266
272
  cdbPassword: request.cdbPassword,
273
+ match: request.match,
267
274
  },
268
275
  env,
269
276
  );
package/src/cli/api.ts CHANGED
@@ -23,7 +23,10 @@ import {
23
23
  formatCentrsErrorText,
24
24
  } from "../errors.ts";
25
25
  import { describeCentrs } from "../index.ts";
26
- import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
26
+ import {
27
+ assertNoMatchInFanout,
28
+ assertNoQuickchrOverrideConflict,
29
+ } from "../resolver/index.ts";
27
30
  import {
28
31
  type CliCommandMetadata,
29
32
  expectValue,
@@ -165,6 +168,12 @@ export const apiCommand: CliCommandMetadata = {
165
168
  valueName: "<password>",
166
169
  description: "Decrypt an encrypted WinBox CDB file.",
167
170
  },
171
+ {
172
+ flag: "--match",
173
+ valueName: "<user=name|target=addr|record-type>",
174
+ description:
175
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
176
+ },
168
177
  {
169
178
  flag: "--resolve",
170
179
  valueName: "<none|arp>",
@@ -330,6 +339,9 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
330
339
  case "--cdb-password":
331
340
  parsed.cdbPassword = expectValue(args, ++index, arg);
332
341
  break;
342
+ case "--match":
343
+ parsed.match = expectValue(args, ++index, arg);
344
+ break;
333
345
  case "--resolve":
334
346
  parsed.resolve = expectValue(args, ++index, arg);
335
347
  break;
@@ -473,6 +485,11 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
473
485
  selectionFlags.quickchr[0] ?? "",
474
486
  );
475
487
  }
488
+ // `--match` + fan-out is refused here too, before `--input -` can block
489
+ // on stdin; `apiFanout` keeps the same check for library callers (#583).
490
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
491
+ assertNoMatchInFanout(parsed.match);
492
+ }
476
493
 
477
494
  // Resolve `--input` (file / stdin) before handing the body to the orchestrator.
478
495
  if (parsed.inputPath !== undefined) {
package/src/cli/btest.ts CHANGED
@@ -145,6 +145,12 @@ export const btestCommand: CliCommandMetadata = {
145
145
  valueName: "<password>",
146
146
  description: "(client) Decrypt an encrypted WinBox CDB file.",
147
147
  },
148
+ {
149
+ flag: "--match",
150
+ valueName: "<user=name|target=addr|record-type>",
151
+ description:
152
+ "(client) Pick one of several CDB records saved for <router> (as `devices show --match`); `btest server` rejects it.",
153
+ },
148
154
  {
149
155
  flag: "--format",
150
156
  valueName: `<${btestOutputFormats.join("|")}>`,
@@ -302,6 +308,9 @@ function parseClientArgs(args: readonly string[]): ClientCliArgs {
302
308
  case "--cdb-password":
303
309
  request.cdbPassword = expectValue(args, ++i, arg);
304
310
  break;
311
+ case "--match":
312
+ request.match = expectValue(args, ++i, arg);
313
+ break;
305
314
  case "--csv":
306
315
  parsed.format = "csv";
307
316
  break;
@@ -22,6 +22,7 @@ import {
22
22
  describeCentrs,
23
23
  devicesOutputFormats,
24
24
  editInteractiveOnlyError,
25
+ encryptCdb,
25
26
  initCdb,
26
27
  listDevices,
27
28
  listGroups,
@@ -48,10 +49,20 @@ import {
48
49
  export const devicesCommand: CliCommandMetadata = {
49
50
  name: "devices",
50
51
  usage:
51
- "centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
52
+ "centrs devices <init|encrypt|list|show|groups|add|edit|set|remove> [args] [flags]",
52
53
  summary:
53
54
  "Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
54
55
  options: [
56
+ {
57
+ flag: "--encrypt",
58
+ description:
59
+ "`init` only — require encrypted creation with a non-empty CDB password; refuse an existing open CDB.",
60
+ },
61
+ {
62
+ flag: "--no-backup",
63
+ description:
64
+ "`encrypt` only — skip the plaintext pre-conversion snapshot. Without that snapshot, a forgotten password cannot be recovered.",
65
+ },
55
66
  {
56
67
  flag: "--cdb-file",
57
68
  valueName: "<path>",
@@ -62,7 +73,7 @@ export const devicesCommand: CliCommandMetadata = {
62
73
  flag: "--cdb-password",
63
74
  valueName: "<secret>",
64
75
  description:
65
- "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used to re-encrypt on writes.",
76
+ "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used for `init --encrypt`, `encrypt`, and to re-encrypt on writes.",
66
77
  },
67
78
  {
68
79
  flag: "--via",
@@ -200,6 +211,7 @@ export const devicesCommand: CliCommandMetadata = {
200
211
 
201
212
  type DevicesSubcommand =
202
213
  | "init"
214
+ | "encrypt"
203
215
  | "list"
204
216
  | "show"
205
217
  | "groups"
@@ -226,6 +238,8 @@ interface DevicesCliArgs {
226
238
  subcommand?: DevicesSubcommand;
227
239
  cdbFile?: string;
228
240
  cdbPassword?: string;
241
+ encrypt?: boolean;
242
+ noBackup?: boolean;
229
243
  group?: string;
230
244
  where?: string[];
231
245
  via?: string;
@@ -254,20 +268,44 @@ interface DevicesCliArgs {
254
268
  bbox?: BboxPredicate;
255
269
  }
256
270
 
271
+ /**
272
+ * Flags `init`/`encrypt` honor. They touch only the CDB file, never a record,
273
+ * so record, selection, and provenance flags would be silently ignored there.
274
+ */
275
+ const CDB_FILE_FLAGS: Readonly<Record<"init" | "encrypt", readonly string[]>> =
276
+ {
277
+ init: ["--cdb-file", "--cdb-password", "--format", "--json", "--encrypt"],
278
+ encrypt: [
279
+ "--cdb-file",
280
+ "--cdb-password",
281
+ "--format",
282
+ "--json",
283
+ "--no-backup",
284
+ ],
285
+ };
286
+
257
287
  function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
258
288
  const parsed: DevicesCliArgs = {};
259
289
  const positional: string[] = [];
290
+ const suppliedFlags: string[] = [];
260
291
 
261
292
  for (let index = 0; index < args.length; index += 1) {
262
293
  const arg = args[index];
263
294
  if (arg === undefined) {
264
295
  continue;
265
296
  }
297
+ if (arg.startsWith("-")) suppliedFlags.push(arg);
266
298
  switch (arg) {
267
299
  case "--help":
268
300
  case "-h":
269
301
  parsed.help = true;
270
302
  break;
303
+ case "--encrypt":
304
+ parsed.encrypt = true;
305
+ break;
306
+ case "--no-backup":
307
+ parsed.noBackup = true;
308
+ break;
271
309
  case "--cdb-file":
272
310
  parsed.cdbFile = expectValue(args, ++index, arg);
273
311
  break;
@@ -382,6 +420,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
382
420
 
383
421
  const subcommands: readonly DevicesSubcommand[] = [
384
422
  "init",
423
+ "encrypt",
385
424
  "list",
386
425
  "show",
387
426
  "groups",
@@ -393,17 +432,23 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
393
432
  const [rawSub, ...rest] = positional;
394
433
  if (rawSub === undefined) {
395
434
  throw new Error(
396
- "`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
435
+ "`centrs devices` requires a subcommand: init, encrypt, list, show, groups, add, edit, set, or remove.",
397
436
  );
398
437
  }
399
438
  // Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
400
439
  const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
401
440
  if (!subcommands.includes(sub)) {
402
441
  throw new Error(
403
- `Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
442
+ `Unknown devices subcommand: ${rawSub}. Use init, encrypt, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
404
443
  );
405
444
  }
406
445
  parsed.subcommand = sub;
446
+ if (parsed.encrypt && sub !== "init")
447
+ throw new Error(
448
+ "--encrypt is supported only by devices init; use devices encrypt for conversion.",
449
+ );
450
+ if (parsed.noBackup && sub !== "encrypt")
451
+ throw new Error("--no-backup is supported only by devices encrypt.");
407
452
 
408
453
  // Reject flags that don't apply to the resolved subcommand instead of
409
454
  // silently ignoring the user's input: `--where`/`--near`/`--bbox` select
@@ -446,6 +491,18 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
446
491
  context: { subcommand: sub, flag: geoFlag },
447
492
  });
448
493
  }
494
+ if (sub === "init" || sub === "encrypt") {
495
+ const allowed = CDB_FILE_FLAGS[sub];
496
+ const ignored = suppliedFlags.find((flag) => !allowed.includes(flag));
497
+ if (ignored !== undefined) {
498
+ throw new CentrsError({
499
+ code: "input/invalid-command",
500
+ summary: `\`${ignored}\` is not supported by \`centrs devices ${sub}\`.`,
501
+ remediation: `\`centrs devices ${sub}\` accepts only ${allowed.map((flag) => `\`${flag}\``).join(", ")}. Edit records with \`centrs devices add\`/\`set\`.`,
502
+ context: { subcommand: sub, flag: ignored },
503
+ });
504
+ }
505
+ }
449
506
 
450
507
  if (sub === "show" || sub === "edit" || sub === "remove") {
451
508
  if (rest.length !== 1) {
@@ -511,7 +568,14 @@ function parseKvArg(token: string): CommentKvUpdate {
511
568
  // canonical key a `--lon`/`--lat`/`--altitude` flag would use. A no-op for
512
569
  // every non-geo key.
513
570
  const key = canonicalizeGeoKey(token.slice(0, eq));
514
- return { key, value: parseKvValue(token.slice(eq + 1)) };
571
+ const value = parseKvValue(token.slice(eq + 1));
572
+ // An empty value removes the token (`devices set <t> via=` deletes the
573
+ // `via` override) instead of storing a literally-empty value, per
574
+ // `commands/devices/README.md` (Comment kv-soup). The core upsert/remove
575
+ // model lives in `applyCommentKv` (`src/resolver/comment-kv.ts`), which
576
+ // treats `value: null` as removal; the CLI lowers `key=` to that form
577
+ // here. There is currently no need to store a literally-empty value.
578
+ return { key, value: value === "" ? null : value };
515
579
  }
516
580
 
517
581
  /**
@@ -610,12 +674,16 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
610
674
  }
611
675
 
612
676
  envSnapshot = Bun.env;
613
- if (parsed.subcommand === "init") {
614
- const envelope = await initCdb({
677
+ if (parsed.subcommand === "init" || parsed.subcommand === "encrypt") {
678
+ const options = {
615
679
  cdbFile: parsed.cdbFile,
616
680
  cdbPassword: parsed.cdbPassword,
617
681
  env: envSnapshot,
618
- });
682
+ };
683
+ const envelope =
684
+ parsed.subcommand === "init"
685
+ ? await initCdb({ ...options, encrypt: parsed.encrypt })
686
+ : await encryptCdb({ ...options, skipBackup: parsed.noBackup });
619
687
  console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
620
688
  return 0;
621
689
  }
@@ -121,6 +121,12 @@ export const executeCommand: CliCommandMetadata = {
121
121
  valueName: "<password>",
122
122
  description: "Decrypt an encrypted WinBox CDB file.",
123
123
  },
124
+ {
125
+ flag: "--match",
126
+ valueName: "<user=name|target=addr|record-type>",
127
+ description:
128
+ "Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
129
+ },
124
130
  {
125
131
  flag: "--resolve",
126
132
  valueName: "<none|arp>",
@@ -262,6 +268,9 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
262
268
  case "--cdb-password":
263
269
  parsed.cdbPassword = expectValue(args, ++index, arg);
264
270
  break;
271
+ case "--match":
272
+ parsed.match = expectValue(args, ++index, arg);
273
+ break;
265
274
  case "--resolve":
266
275
  parsed.resolve = expectValue(args, ++index, arg);
267
276
  break;