@tikoci/centrs 0.1.0

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 (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * `centrs devices` CLI surface: command metadata, subcommand parsing, dispatch,
3
+ * and error-to-envelope rendering. Behavior, flags, exit codes, and output are
4
+ * unchanged from the former monolithic `cli.ts`.
5
+ */
6
+
7
+ import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
8
+ import {
9
+ addDevice,
10
+ buildDevicesErrorEnvelope,
11
+ type CommentKvUpdate,
12
+ type DevicesCommand,
13
+ type DevicesEnvelope,
14
+ type DevicesOutputFormat,
15
+ describeCentrs,
16
+ devicesOutputFormats,
17
+ editInteractiveOnlyError,
18
+ listDevices,
19
+ listGroups,
20
+ loadCdb,
21
+ recordTypeFromName,
22
+ removeDevice,
23
+ renderDevicesEnvelope,
24
+ resolveDevicesSettings,
25
+ setDevice,
26
+ showDevice,
27
+ winBoxCdbRecordType,
28
+ } from "../index.ts";
29
+ import {
30
+ type CliCommandMetadata,
31
+ expectValue,
32
+ renderCommandHelp,
33
+ } from "./common.ts";
34
+
35
+ export const devicesCommand: CliCommandMetadata = {
36
+ name: "devices",
37
+ usage: "centrs devices <list|show|groups|add|edit|set|remove> [args] [flags]",
38
+ summary:
39
+ "Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
40
+ options: [
41
+ {
42
+ flag: "--cdb-file",
43
+ valueName: "<path>",
44
+ description:
45
+ "Override the resolved CDB path (default `~/.config/tikoci/winbox.cdb`).",
46
+ },
47
+ {
48
+ flag: "--cdb-password",
49
+ valueName: "<secret>",
50
+ description:
51
+ "Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used to re-encrypt on writes.",
52
+ },
53
+ {
54
+ flag: "--via",
55
+ valueName: "<protocol>",
56
+ description:
57
+ "Resolve and report the protocol source for provenance examples; no network IO is performed.",
58
+ },
59
+ {
60
+ flag: "--group",
61
+ valueName: "<name>",
62
+ description:
63
+ "`list` filter / `add`,`edit` first-class group field for the target entry.",
64
+ },
65
+ {
66
+ flag: "--members",
67
+ description: "`groups` only — expand each group's membership.",
68
+ },
69
+ {
70
+ flag: "--explain",
71
+ description:
72
+ "`show` only — include the raw `WinBoxCdbRecord` in `data.record`.",
73
+ },
74
+ {
75
+ flag: "--match",
76
+ valueName: "<user=…|target=…|record-type>",
77
+ description:
78
+ "`show` — disambiguate when `<router>` matches several records: `user=<name>`, `target=<addr>`, or a record-type token.",
79
+ },
80
+ {
81
+ flag: "--user",
82
+ valueName: "<name>",
83
+ description: "`add`,`set` — first-class CDB user field.",
84
+ },
85
+ {
86
+ flag: "--password",
87
+ valueName: "<secret>",
88
+ description: "`add`,`set` — first-class CDB password field.",
89
+ },
90
+ {
91
+ flag: "--profile",
92
+ valueName: "<name>",
93
+ description: "`add`,`set` — named WinBox profile (Workspace).",
94
+ },
95
+ {
96
+ flag: "--profile-none",
97
+ description: "`add`,`set` — write the WinBox `<none>` profile sentinel.",
98
+ },
99
+ {
100
+ flag: "--profile-own",
101
+ description: "`add`,`set` — write the WinBox `<own>` profile sentinel.",
102
+ },
103
+ {
104
+ flag: "--session",
105
+ valueName: "<name>",
106
+ description: "`add`,`set` — first-class CDB session field.",
107
+ },
108
+ {
109
+ flag: "--comment",
110
+ valueName: "<text>",
111
+ description: "`add` only — base free-form comment (may include kv-soup).",
112
+ },
113
+ {
114
+ flag: "--record-type",
115
+ valueName: `<${Object.keys(winBoxCdbRecordType).join("|")}>`,
116
+ description: "`add` only — record type (default `ipAdmin`).",
117
+ },
118
+ {
119
+ flag: "--force",
120
+ description: "`add` only — overwrite the existing (target, user) entry.",
121
+ },
122
+ {
123
+ flag: "--strict",
124
+ description:
125
+ "`add`,`set` — reject unknown comment kv keys instead of warning.",
126
+ },
127
+ {
128
+ flag: "--format",
129
+ valueName: `<${devicesOutputFormats.join("|")}>`,
130
+ description: "Output format for the CLI response.",
131
+ },
132
+ {
133
+ flag: "--json",
134
+ description: "Shortcut for `--format json`.",
135
+ },
136
+ ],
137
+ };
138
+
139
+ type DevicesSubcommand =
140
+ | "list"
141
+ | "show"
142
+ | "groups"
143
+ | "add"
144
+ | "edit"
145
+ | "set"
146
+ | "remove";
147
+
148
+ /**
149
+ * RouterOS-muscle-memory aliases that resolve silently to the canonical verb
150
+ * (see `commands/AGENTS.md`, Verb vocabulary). Canonical names still win in help
151
+ * text and the usage line; these are a CLI input affordance only — the MCP `op`
152
+ * enum and the API functions stay canonical-only.
153
+ */
154
+ const SUBCOMMAND_ALIASES: Readonly<Record<string, DevicesSubcommand>> = {
155
+ print: "list",
156
+ get: "show",
157
+ rm: "remove",
158
+ delete: "remove",
159
+ };
160
+
161
+ interface DevicesCliArgs {
162
+ help?: boolean;
163
+ subcommand?: DevicesSubcommand;
164
+ cdbFile?: string;
165
+ cdbPassword?: string;
166
+ group?: string;
167
+ via?: string;
168
+ members?: boolean;
169
+ explain?: boolean;
170
+ match?: string;
171
+ user?: string;
172
+ password?: string;
173
+ profile?: string;
174
+ profileNone?: boolean;
175
+ profileOwn?: boolean;
176
+ session?: string;
177
+ comment?: string;
178
+ recordType?: string;
179
+ force?: boolean;
180
+ strict?: boolean;
181
+ format?: DevicesOutputFormat;
182
+ target?: string;
183
+ kvArgs?: string[];
184
+ }
185
+
186
+ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
187
+ const parsed: DevicesCliArgs = {};
188
+ const positional: string[] = [];
189
+
190
+ for (let index = 0; index < args.length; index += 1) {
191
+ const arg = args[index];
192
+ if (arg === undefined) {
193
+ continue;
194
+ }
195
+ switch (arg) {
196
+ case "--help":
197
+ case "-h":
198
+ parsed.help = true;
199
+ break;
200
+ case "--cdb-file":
201
+ parsed.cdbFile = expectValue(args, ++index, arg);
202
+ break;
203
+ case "--cdb-password":
204
+ parsed.cdbPassword = expectValue(args, ++index, arg);
205
+ break;
206
+ case "--group":
207
+ parsed.group = expectValue(args, ++index, arg);
208
+ break;
209
+ case "--via":
210
+ parsed.via = expectValue(args, ++index, arg);
211
+ break;
212
+ case "--members":
213
+ parsed.members = true;
214
+ break;
215
+ case "--explain":
216
+ parsed.explain = true;
217
+ break;
218
+ case "--match":
219
+ parsed.match = expectValue(args, ++index, arg);
220
+ break;
221
+ case "--user":
222
+ parsed.user = expectValue(args, ++index, arg);
223
+ break;
224
+ case "--password":
225
+ parsed.password = expectValue(args, ++index, arg);
226
+ break;
227
+ case "--profile":
228
+ parsed.profile = expectValue(args, ++index, arg);
229
+ break;
230
+ case "--profile-none":
231
+ parsed.profileNone = true;
232
+ break;
233
+ case "--profile-own":
234
+ parsed.profileOwn = true;
235
+ break;
236
+ case "--session":
237
+ parsed.session = expectValue(args, ++index, arg);
238
+ break;
239
+ case "--comment":
240
+ parsed.comment = expectValue(args, ++index, arg);
241
+ break;
242
+ case "--record-type":
243
+ parsed.recordType = expectValue(args, ++index, arg);
244
+ break;
245
+ case "--force":
246
+ parsed.force = true;
247
+ break;
248
+ case "--strict":
249
+ parsed.strict = true;
250
+ break;
251
+ case "--format": {
252
+ const value = expectValue(args, ++index, arg);
253
+ if (!devicesOutputFormats.includes(value as DevicesOutputFormat)) {
254
+ throw new Error(
255
+ `--format must be one of ${devicesOutputFormats.join(", ")}; got ${value}.`,
256
+ );
257
+ }
258
+ parsed.format = value as DevicesOutputFormat;
259
+ break;
260
+ }
261
+ case "--json":
262
+ parsed.format = "json";
263
+ break;
264
+ default:
265
+ if (arg.startsWith("-")) {
266
+ throw new Error(`Unknown devices flag: ${arg}`);
267
+ }
268
+ positional.push(arg);
269
+ break;
270
+ }
271
+ }
272
+
273
+ if (parsed.help) {
274
+ return parsed;
275
+ }
276
+
277
+ const subcommands: readonly DevicesSubcommand[] = [
278
+ "list",
279
+ "show",
280
+ "groups",
281
+ "add",
282
+ "edit",
283
+ "set",
284
+ "remove",
285
+ ];
286
+ const [rawSub, ...rest] = positional;
287
+ if (rawSub === undefined) {
288
+ throw new Error(
289
+ "`centrs devices` requires a subcommand: list, show, groups, add, edit, set, or remove.",
290
+ );
291
+ }
292
+ // Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
293
+ const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
294
+ if (!subcommands.includes(sub)) {
295
+ throw new Error(
296
+ `Unknown devices subcommand: ${rawSub}. Use list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
297
+ );
298
+ }
299
+ parsed.subcommand = sub;
300
+
301
+ if (sub === "show" || sub === "edit" || sub === "remove") {
302
+ if (rest.length !== 1) {
303
+ throw new Error(
304
+ `\`centrs devices ${sub}\` requires exactly one <target>.`,
305
+ );
306
+ }
307
+ parsed.target = rest[0];
308
+ } else if (sub === "add" || sub === "set") {
309
+ // add/set are symmetric: <target> followed by optional key=value tokens.
310
+ const [target, ...kvArgs] = rest;
311
+ if (target === undefined) {
312
+ throw new Error(
313
+ `\`centrs devices ${sub}\` requires a <target>, optionally followed by key=value tokens.`,
314
+ );
315
+ }
316
+ parsed.target = target;
317
+ parsed.kvArgs = kvArgs;
318
+ } else if (rest.length > 0) {
319
+ throw new Error(
320
+ `\`centrs devices ${sub}\` does not accept positional arguments; got: ${rest.join(", ")}.`,
321
+ );
322
+ }
323
+
324
+ return parsed;
325
+ }
326
+
327
+ function parseKvArg(token: string): CommentKvUpdate {
328
+ const eq = token.indexOf("=");
329
+ if (eq <= 0) {
330
+ throw new Error(
331
+ `Invalid kv override "${token}"; expected key=value (use key= to clear a value).`,
332
+ );
333
+ }
334
+ return { key: token.slice(0, eq), value: parseKvValue(token.slice(eq + 1)) };
335
+ }
336
+
337
+ function parseKvValue(value: string): string {
338
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
339
+ return value;
340
+ }
341
+ let parsed = "";
342
+ for (let index = 1; index < value.length - 1; index += 1) {
343
+ const char = value[index];
344
+ if (char === "\\" && index + 1 < value.length - 1) {
345
+ const next = value[index + 1];
346
+ if (next === '"' || next === "\\") {
347
+ parsed += next;
348
+ index += 1;
349
+ continue;
350
+ }
351
+ }
352
+ parsed += char;
353
+ }
354
+ return parsed;
355
+ }
356
+
357
+ /**
358
+ * Resolve the profile field from `--profile` / `--profile-none` / `--profile-own`
359
+ * (mutually exclusive). The sentinels map to the WinBox `<none>` / `<own>`
360
+ * values; returns `undefined` when none was supplied so the field is left
361
+ * unchanged on `set` and defaulted by the builder on `add`.
362
+ */
363
+ function resolveProfile(parsed: DevicesCliArgs): string | undefined {
364
+ const supplied = [
365
+ parsed.profile !== undefined,
366
+ parsed.profileNone === true,
367
+ parsed.profileOwn === true,
368
+ ].filter(Boolean).length;
369
+ if (supplied > 1) {
370
+ throw new Error(
371
+ "--profile, --profile-none, and --profile-own are mutually exclusive.",
372
+ );
373
+ }
374
+ if (parsed.profileNone) {
375
+ return "<none>";
376
+ }
377
+ if (parsed.profileOwn) {
378
+ return "<own>";
379
+ }
380
+ return parsed.profile;
381
+ }
382
+
383
+ export async function runDevicesCli(args: readonly string[]): Promise<number> {
384
+ let parsed: DevicesCliArgs | undefined;
385
+ let envSnapshot: Record<string, string | undefined> | undefined;
386
+ try {
387
+ parsed = parseDevicesCliArgs(args);
388
+ if (parsed.help) {
389
+ console.log(renderCommandHelp(describeCentrs(), devicesCommand));
390
+ return 0;
391
+ }
392
+
393
+ envSnapshot = Bun.env;
394
+ const cdb = await loadCdb({
395
+ cdbFile: parsed.cdbFile,
396
+ cdbPassword: parsed.cdbPassword,
397
+ env: envSnapshot,
398
+ });
399
+
400
+ let envelope: DevicesEnvelope<unknown>;
401
+ switch (parsed.subcommand) {
402
+ case "list":
403
+ envelope = listDevices({ cdb, group: parsed.group });
404
+ break;
405
+ case "show":
406
+ if (!parsed.target) {
407
+ throw new Error("Missing <target> for devices show.");
408
+ }
409
+ envelope = showDevice({
410
+ cdb,
411
+ target: parsed.target,
412
+ explain: parsed.explain,
413
+ match: parsed.match,
414
+ via: parsed.via,
415
+ env: envSnapshot,
416
+ });
417
+ break;
418
+ case "groups":
419
+ envelope = listGroups({ cdb, withMembers: parsed.members });
420
+ break;
421
+ case "add": {
422
+ if (!parsed.target) {
423
+ throw new Error("Missing <target> for devices add.");
424
+ }
425
+ let recordType: number | undefined;
426
+ if (parsed.recordType !== undefined) {
427
+ recordType = recordTypeFromName(parsed.recordType);
428
+ if (recordType === undefined) {
429
+ throw new Error(
430
+ `--record-type must be one of ${Object.keys(winBoxCdbRecordType).join(", ")}; got ${parsed.recordType}.`,
431
+ );
432
+ }
433
+ }
434
+ envelope = await addDevice({
435
+ cdb,
436
+ target: parsed.target,
437
+ recordType,
438
+ user: parsed.user,
439
+ password: parsed.password,
440
+ group: parsed.group,
441
+ profile: resolveProfile(parsed),
442
+ session: parsed.session,
443
+ comment: parsed.comment,
444
+ commentKvUpdates: (parsed.kvArgs ?? []).map(parseKvArg),
445
+ force: parsed.force,
446
+ strict: parsed.strict,
447
+ });
448
+ break;
449
+ }
450
+ case "edit":
451
+ // The interactive editor (clack/TUI) is reserved for the future; the
452
+ // non-interactive path is `devices set`.
453
+ throw editInteractiveOnlyError();
454
+ case "set":
455
+ if (!parsed.target) {
456
+ throw new Error("Missing <target> for devices set.");
457
+ }
458
+ envelope = await setDevice({
459
+ cdb,
460
+ target: parsed.target,
461
+ match: parsed.match,
462
+ updates: (parsed.kvArgs ?? []).map(parseKvArg),
463
+ user: parsed.user,
464
+ password: parsed.password,
465
+ group: parsed.group,
466
+ profile: resolveProfile(parsed),
467
+ session: parsed.session,
468
+ strict: parsed.strict,
469
+ });
470
+ break;
471
+ case "remove":
472
+ if (!parsed.target) {
473
+ throw new Error("Missing <target> for devices remove.");
474
+ }
475
+ envelope = await removeDevice({
476
+ cdb,
477
+ target: parsed.target,
478
+ match: parsed.match,
479
+ });
480
+ break;
481
+ default:
482
+ throw new Error("Unreachable devices subcommand.");
483
+ }
484
+
485
+ const format = parsed.format ?? "text";
486
+ console.log(renderDevicesEnvelope(envelope, format));
487
+ return 0;
488
+ } catch (error) {
489
+ const format = parsed?.format ?? inferDevicesFormat(args);
490
+ const subcommand: DevicesCommand = parsed?.subcommand ?? "list";
491
+ const settings = resolveDevicesSettings({
492
+ cdbFile: parsed?.cdbFile,
493
+ cdbPassword: parsed?.cdbPassword,
494
+ env: envSnapshot ?? Bun.env,
495
+ });
496
+ const centrsError = asCentrsError(error, {
497
+ code: "input/invalid-command",
498
+ summary: error instanceof Error ? error.message : String(error),
499
+ remediation:
500
+ "Use `centrs devices --help` to inspect the supported subcommands and flags.",
501
+ });
502
+ const envelope = buildDevicesErrorEnvelope(
503
+ subcommand,
504
+ settings,
505
+ [],
506
+ centrsError,
507
+ );
508
+ if (format === "json" || format === "yaml") {
509
+ console.error(renderDevicesEnvelope(envelope, format));
510
+ } else {
511
+ console.error(formatCentrsErrorText(centrsError));
512
+ }
513
+ return 1;
514
+ }
515
+ }
516
+
517
+ function inferDevicesFormat(args: readonly string[]): DevicesOutputFormat {
518
+ if (args.includes("--json")) {
519
+ return "json";
520
+ }
521
+ const formatIndex = args.indexOf("--format");
522
+ const value = formatIndex >= 0 ? args[formatIndex + 1] : undefined;
523
+ if (value && devicesOutputFormats.includes(value as DevicesOutputFormat)) {
524
+ return value as DevicesOutputFormat;
525
+ }
526
+ return "text";
527
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `centrs discover` CLI surface: command metadata, arg parsing, dispatch, and
3
+ * error-to-envelope rendering. `discover` listens for MNDP neighbor
4
+ * announcements and, with `--save`, persists them into the CDB through the
5
+ * shared `devices` write layer.
6
+ */
7
+
8
+ import {
9
+ type DiscoverOptions,
10
+ type DiscoverOutputFormat,
11
+ discover,
12
+ discoverOutputFormats,
13
+ renderDiscoverEnvelope,
14
+ } from "../discover.ts";
15
+ import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
16
+ import { describeCentrs, parseDuration } from "../index.ts";
17
+ import {
18
+ type CliCommandMetadata,
19
+ expectValue,
20
+ renderCommandHelp,
21
+ } from "./common.ts";
22
+
23
+ export const discoverCommand: CliCommandMetadata = {
24
+ name: "discover",
25
+ usage: "centrs discover [--timeout 15s] [--save] [flags]",
26
+ summary:
27
+ "Discover RouterOS neighbors over MNDP and optionally save them into the CDB.",
28
+ options: [
29
+ {
30
+ flag: "--timeout",
31
+ valueName: "<ms|15s>",
32
+ description: "Listen window before results are returned. Default 15s.",
33
+ },
34
+ {
35
+ flag: "--save",
36
+ description:
37
+ "Persist discovered neighbors into the CDB (`group=discovered`, `source=mndp`).",
38
+ },
39
+ {
40
+ flag: "--group",
41
+ valueName: "<name>",
42
+ description: "`--save` group for new entries. Default `discovered`.",
43
+ },
44
+ {
45
+ flag: "--port",
46
+ valueName: "<port>",
47
+ description: "UDP port to bind for MNDP. Default 5678.",
48
+ },
49
+ {
50
+ flag: "--cdb-file",
51
+ valueName: "<path>",
52
+ description:
53
+ "`--save` CDB path override (default `~/.config/tikoci/winbox.cdb`).",
54
+ },
55
+ {
56
+ flag: "--cdb-password",
57
+ valueName: "<secret>",
58
+ description:
59
+ "`--save` password for an encrypted CDB. Used to decrypt the existing CDB and re-encrypt on write.",
60
+ },
61
+ {
62
+ flag: "--format",
63
+ valueName: `<${discoverOutputFormats.join("|")}>`,
64
+ description: "Output format for the CLI response.",
65
+ },
66
+ {
67
+ flag: "--json",
68
+ description: "Shortcut for `--format json`.",
69
+ },
70
+ ],
71
+ };
72
+
73
+ interface DiscoverCliArgs {
74
+ help?: boolean;
75
+ timeoutMs?: number;
76
+ save?: boolean;
77
+ group?: string;
78
+ port?: number;
79
+ cdbFile?: string;
80
+ cdbPassword?: string;
81
+ format?: DiscoverOutputFormat;
82
+ }
83
+
84
+ function parseDiscoverCliArgs(args: readonly string[]): DiscoverCliArgs {
85
+ const parsed: DiscoverCliArgs = {};
86
+ for (let index = 0; index < args.length; index += 1) {
87
+ const arg = args[index];
88
+ if (arg === undefined) {
89
+ continue;
90
+ }
91
+ switch (arg) {
92
+ case "--help":
93
+ case "-h":
94
+ parsed.help = true;
95
+ break;
96
+ case "--timeout":
97
+ parsed.timeoutMs = parseDuration(expectValue(args, ++index, arg));
98
+ break;
99
+ case "--save":
100
+ parsed.save = true;
101
+ break;
102
+ case "--group":
103
+ parsed.group = expectValue(args, ++index, arg);
104
+ break;
105
+ case "--port": {
106
+ const value = expectValue(args, ++index, arg);
107
+ const port = Number.parseInt(value, 10);
108
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
109
+ throw new Error(`--port must be an integer 0-65535; got ${value}.`);
110
+ }
111
+ parsed.port = port;
112
+ break;
113
+ }
114
+ case "--cdb-file":
115
+ parsed.cdbFile = expectValue(args, ++index, arg);
116
+ break;
117
+ case "--cdb-password":
118
+ parsed.cdbPassword = expectValue(args, ++index, arg);
119
+ break;
120
+ case "--format": {
121
+ const value = expectValue(args, ++index, arg);
122
+ if (!discoverOutputFormats.includes(value as DiscoverOutputFormat)) {
123
+ throw new Error(
124
+ `--format must be one of ${discoverOutputFormats.join(", ")}; got ${value}.`,
125
+ );
126
+ }
127
+ parsed.format = value as DiscoverOutputFormat;
128
+ break;
129
+ }
130
+ case "--json":
131
+ parsed.format = "json";
132
+ break;
133
+ default:
134
+ if (arg.startsWith("-")) {
135
+ throw new Error(`Unknown discover flag: ${arg}`);
136
+ }
137
+ throw new Error(
138
+ `\`centrs discover\` does not accept positional arguments; got: ${arg}.`,
139
+ );
140
+ }
141
+ }
142
+ return parsed;
143
+ }
144
+
145
+ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
146
+ let parsed: DiscoverCliArgs | undefined;
147
+ try {
148
+ parsed = parseDiscoverCliArgs(args);
149
+ if (parsed.help) {
150
+ console.log(renderCommandHelp(describeCentrs(), discoverCommand));
151
+ return 0;
152
+ }
153
+
154
+ const options: DiscoverOptions = {
155
+ timeoutMs: parsed.timeoutMs,
156
+ save: parsed.save,
157
+ group: parsed.group,
158
+ port: parsed.port,
159
+ cdbFile: parsed.cdbFile,
160
+ cdbPassword: parsed.cdbPassword,
161
+ env: Bun.env,
162
+ };
163
+ const envelope = await discover(options);
164
+ const format = parsed.format ?? "text";
165
+ const rendered = renderDiscoverEnvelope(envelope, format);
166
+ if (envelope.ok) {
167
+ console.log(rendered);
168
+ return 0;
169
+ }
170
+ console.error(
171
+ format === "json" || format === "yaml"
172
+ ? rendered
173
+ : formatCentrsErrorText(envelope.error),
174
+ );
175
+ return 1;
176
+ } catch (error) {
177
+ const centrsError = asCentrsError(error, {
178
+ code: "input/invalid-command",
179
+ summary: error instanceof Error ? error.message : String(error),
180
+ remediation:
181
+ "Use `centrs discover --help` to inspect the supported flags.",
182
+ });
183
+ console.error(formatCentrsErrorText(centrsError));
184
+ return 1;
185
+ }
186
+ }