@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,383 @@
1
+ /**
2
+ * `centrs retrieve` CLI surface: command metadata, arg parsing, dispatch, and
3
+ * 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
+ buildRetrieveErrorEnvelope,
10
+ buildRetrieveFanoutErrorEnvelope,
11
+ describeCentrs,
12
+ type RetrieveOutputFormat,
13
+ type RetrieveRequest,
14
+ renderRetrieveEnvelope,
15
+ renderRetrieveFanoutEnvelope,
16
+ retrieve,
17
+ retrieveGroup,
18
+ retrieveOutputFormats,
19
+ } from "../index.ts";
20
+ import {
21
+ type CliCommandMetadata,
22
+ expectValue,
23
+ renderCommandHelp,
24
+ } from "./common.ts";
25
+
26
+ export const retrieveCommand: CliCommandMetadata = {
27
+ name: "retrieve",
28
+ usage:
29
+ "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
30
+ summary:
31
+ "Read RouterOS values through the shared core using the selected protocol.",
32
+ options: [
33
+ {
34
+ flag: "--via",
35
+ valueName: "<protocol>",
36
+ description:
37
+ "Pin the protocol selector. Defaults to `rest-api` for retrieve.",
38
+ },
39
+ {
40
+ flag: "--group",
41
+ valueName: "<name>",
42
+ description:
43
+ "Fan out over every CDB record in the group. Replaces the <target> positional.",
44
+ },
45
+ {
46
+ flag: "--concurrency",
47
+ valueName: "<n>",
48
+ description:
49
+ "Bounded worker-pool size for `--group` fanout (REST 8, native-api 4 by default).",
50
+ },
51
+ {
52
+ flag: "--host",
53
+ valueName: "<host|url>",
54
+ description: "Override the resolved host or base URL for the target.",
55
+ },
56
+ {
57
+ flag: "--port",
58
+ valueName: "<port>",
59
+ description: "Override the resolved management port.",
60
+ },
61
+ {
62
+ flag: "--username / --user / -u",
63
+ valueName: "<name>",
64
+ description:
65
+ "RouterOS username (aliases `--user`, `-u`). Falls back to `CENTRS_USERNAME`.",
66
+ },
67
+ {
68
+ flag: "--password",
69
+ valueName: "<secret>",
70
+ description: "RouterOS password. Falls back to `CENTRS_PASSWORD`.",
71
+ },
72
+ {
73
+ flag: "--timeout",
74
+ valueName: "<ms|5s>",
75
+ description:
76
+ "Operation timeout. REST currently rejects values above 60s.",
77
+ },
78
+ {
79
+ flag: "--attribute",
80
+ valueName: "<name>",
81
+ description: "Project one attribute. May be repeated.",
82
+ },
83
+ {
84
+ flag: "--attributes",
85
+ valueName: "<a,b>",
86
+ description: "Project a comma-separated attribute list.",
87
+ },
88
+ {
89
+ flag: "--all-attributes",
90
+ description: "Request the RouterOS detail/all-attributes shape.",
91
+ },
92
+ {
93
+ flag: "--list-attributes",
94
+ description:
95
+ "List inspect-derived attributes without running the data call.",
96
+ },
97
+ {
98
+ flag: "--format",
99
+ valueName: `<${retrieveOutputFormats.join("|")}>`,
100
+ description:
101
+ "Output format for the CLI response. Defaults to text; use --json or --format json for the structured envelope.",
102
+ },
103
+ {
104
+ flag: "--json",
105
+ description: "Shortcut for `--format json`.",
106
+ },
107
+ {
108
+ flag: "--max-results",
109
+ valueName: "<bytes>",
110
+ description:
111
+ "Fail instead of printing output larger than the given byte budget.",
112
+ },
113
+ {
114
+ flag: "--cdb-file",
115
+ valueName: "<path>",
116
+ description: "Read target credentials from a WinBox CDB file.",
117
+ },
118
+ {
119
+ flag: "--cdb-password",
120
+ valueName: "<password>",
121
+ description: "Decrypt an encrypted WinBox CDB file.",
122
+ },
123
+ {
124
+ flag: "--resolve",
125
+ valueName: "<none|arp>",
126
+ description:
127
+ "Resolve a MAC-address target to an IP via the host ARP cache (default none).",
128
+ },
129
+ {
130
+ flag: "--validate / --no-validate",
131
+ description: "Enable or disable inspect-backed preflight validation.",
132
+ },
133
+ {
134
+ flag: "--verbose",
135
+ description: "Show resolved setting sources in text output.",
136
+ },
137
+ ],
138
+ };
139
+
140
+ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
141
+ let request: RetrieveRequest | undefined;
142
+
143
+ try {
144
+ request = parseRetrieveCliArgs(args);
145
+ if ((request as { help?: boolean }).help) {
146
+ console.log(renderCommandHelp(describeCentrs(), retrieveCommand));
147
+ return 0;
148
+ }
149
+
150
+ const envelope = request.group
151
+ ? await retrieveGroup(request)
152
+ : await retrieve(request);
153
+ const resolvedFormat =
154
+ (
155
+ envelope.meta.operation as {
156
+ request?: { format?: RetrieveOutputFormat };
157
+ }
158
+ )?.request?.format ?? "text";
159
+ const output = request.group
160
+ ? renderRetrieveFanoutEnvelope(
161
+ envelope as Parameters<typeof renderRetrieveFanoutEnvelope>[0],
162
+ resolvedFormat,
163
+ { verbose: request.verbose },
164
+ )
165
+ : renderRetrieveEnvelope(
166
+ envelope as Parameters<typeof renderRetrieveEnvelope>[0],
167
+ resolvedFormat,
168
+ { verbose: request.verbose },
169
+ );
170
+ console.log(output);
171
+ return 0;
172
+ } catch (error) {
173
+ const format = inferRequestedFormat(args, request);
174
+ if (format === "json" || format === "yaml") {
175
+ if (request?.group) {
176
+ const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
177
+ console.error(
178
+ renderRetrieveFanoutEnvelope(envelope, format, {
179
+ verbose: request?.verbose ?? false,
180
+ }),
181
+ );
182
+ } else {
183
+ const envelope = buildRetrieveErrorEnvelope(
184
+ request ?? fallbackRequestFromArgs(args),
185
+ error,
186
+ );
187
+ console.error(
188
+ renderRetrieveEnvelope(envelope, format, {
189
+ verbose: request?.verbose ?? false,
190
+ }),
191
+ );
192
+ }
193
+ } else {
194
+ console.error(
195
+ formatCentrsErrorText(
196
+ asCentrsError(error, {
197
+ code: "input/invalid-command",
198
+ summary: error instanceof Error ? error.message : String(error),
199
+ remediation:
200
+ "Use `centrs retrieve --help` to inspect the supported command shape and flags.",
201
+ }),
202
+ {
203
+ verbose: request?.verbose ?? args.includes("--verbose"),
204
+ },
205
+ ),
206
+ );
207
+ }
208
+ return 1;
209
+ }
210
+ }
211
+
212
+ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
213
+ help?: boolean;
214
+ } {
215
+ const request: RetrieveRequest & { help?: boolean } = {
216
+ path: "",
217
+ };
218
+ const positional: string[] = [];
219
+
220
+ for (let index = 0; index < args.length; index += 1) {
221
+ const arg = args[index];
222
+ if (arg === undefined) {
223
+ continue;
224
+ }
225
+ switch (arg) {
226
+ case "--help":
227
+ case "-h":
228
+ request.help = true;
229
+ break;
230
+ case "--via":
231
+ request.via = expectValue(args, ++index, arg);
232
+ break;
233
+ case "--host":
234
+ request.host = expectValue(args, ++index, arg);
235
+ break;
236
+ case "--port":
237
+ request.port = Number.parseInt(expectValue(args, ++index, arg), 10);
238
+ break;
239
+ case "--user":
240
+ case "-u":
241
+ case "--username":
242
+ request.username = expectValue(args, ++index, arg);
243
+ break;
244
+ case "--password":
245
+ request.password = expectValue(args, ++index, arg);
246
+ break;
247
+ case "--timeout":
248
+ request.timeout = expectValue(args, ++index, arg);
249
+ break;
250
+ case "--format":
251
+ request.format = expectValue(args, ++index, arg);
252
+ break;
253
+ case "--json":
254
+ request.format = "json";
255
+ break;
256
+ case "--attribute": {
257
+ const value = expectValue(args, ++index, arg);
258
+ request.attribute = [
259
+ ...(Array.isArray(request.attribute) ? request.attribute : []),
260
+ value,
261
+ ];
262
+ break;
263
+ }
264
+ case "--attributes":
265
+ request.attributes = [
266
+ ...(Array.isArray(request.attributes) ? request.attributes : []),
267
+ expectValue(args, ++index, arg),
268
+ ];
269
+ break;
270
+ case "--all-attributes":
271
+ request.allAttributes = true;
272
+ break;
273
+ case "--list":
274
+ case "--list-attributes":
275
+ request.listAttributes = true;
276
+ break;
277
+ case "--filter":
278
+ request.filter = expectValue(args, ++index, arg);
279
+ break;
280
+ case "--query":
281
+ request.query = expectValue(args, ++index, arg);
282
+ break;
283
+ case "--max-results":
284
+ request.maxResultsBytes = Number.parseInt(
285
+ expectValue(args, ++index, arg),
286
+ 10,
287
+ );
288
+ break;
289
+ case "--cdb-file":
290
+ request.cdbFile = expectValue(args, ++index, arg);
291
+ break;
292
+ case "--cdb-password":
293
+ request.cdbPassword = expectValue(args, ++index, arg);
294
+ break;
295
+ case "--resolve":
296
+ request.resolve = expectValue(args, ++index, arg);
297
+ break;
298
+ case "--group":
299
+ request.group = expectValue(args, ++index, arg);
300
+ break;
301
+ case "--concurrency":
302
+ request.concurrency = Number.parseInt(
303
+ expectValue(args, ++index, arg),
304
+ 10,
305
+ );
306
+ break;
307
+ case "--validate":
308
+ request.validate = true;
309
+ break;
310
+ case "--no-validate":
311
+ request.validate = false;
312
+ break;
313
+ case "--verbose":
314
+ request.verbose = true;
315
+ break;
316
+ default:
317
+ if (arg.startsWith("-")) {
318
+ throw new Error(`Unknown retrieve flag: ${arg}`);
319
+ }
320
+ positional.push(arg);
321
+ break;
322
+ }
323
+ }
324
+
325
+ if (request.help) {
326
+ return request;
327
+ }
328
+
329
+ if (request.group !== undefined) {
330
+ if (positional.length < 1) {
331
+ throw new Error(
332
+ "`centrs retrieve --group <name>` requires a <routeros-path>.",
333
+ );
334
+ }
335
+ if (positional.length > 1) {
336
+ throw new Error(
337
+ "`centrs retrieve --group <name>` takes only a <routeros-path>; the group replaces the <target> positional.",
338
+ );
339
+ }
340
+ request.path = positional[0] ?? "";
341
+ return request;
342
+ }
343
+
344
+ if (positional.length < 2) {
345
+ throw new Error(
346
+ "`centrs retrieve` requires both <target> and <routeros-path>.",
347
+ );
348
+ }
349
+
350
+ request.targetInput = positional[0];
351
+ request.path = positional[1] ?? "";
352
+ return request;
353
+ }
354
+
355
+ function inferRequestedFormat(
356
+ args: readonly string[],
357
+ request?: RetrieveRequest,
358
+ ): RetrieveOutputFormat {
359
+ if (request?.format) {
360
+ return request.format as RetrieveOutputFormat;
361
+ }
362
+ if (args.includes("--json")) {
363
+ return "json";
364
+ }
365
+ const formatIndex = args.indexOf("--format");
366
+ if (formatIndex !== -1 && args[formatIndex + 1]) {
367
+ return args[formatIndex + 1] as RetrieveOutputFormat;
368
+ }
369
+ const envFormat = process.env["CENTRS_FORMAT"];
370
+ if (envFormat === "json" || envFormat === "yaml" || envFormat === "text") {
371
+ return envFormat;
372
+ }
373
+ return "text";
374
+ }
375
+
376
+ function fallbackRequestFromArgs(args: readonly string[]): RetrieveRequest {
377
+ const positionals = args.filter((arg) => !arg.startsWith("-"));
378
+ return {
379
+ targetInput: positionals[0],
380
+ path: positionals[1] ?? "",
381
+ format: inferRequestedFormat(args),
382
+ };
383
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * `centrs terminal` CLI surface: open an interactive RouterOS console over
3
+ * mac-telnet. Device output streams to **stdout**; a pre-stream failure renders
4
+ * the standard error envelope to **stderr** and exits 1. Mirrors the shape of
5
+ * `./transfer.ts`. The process-backed {@link TerminalIo} lives here (it touches
6
+ * `process.*`); the core relay in `../terminal.ts` stays io-agnostic.
7
+ */
8
+
9
+ import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
10
+ import {
11
+ buildTerminalErrorEnvelope,
12
+ describeCentrs,
13
+ runTerminal,
14
+ type TerminalIo,
15
+ type TerminalRequest,
16
+ } from "../index.ts";
17
+ import { toYaml } from "../retrieve.ts";
18
+ import {
19
+ type CliCommandMetadata,
20
+ expectValue,
21
+ renderCommandHelp,
22
+ } from "./common.ts";
23
+
24
+ export const terminalCommand: CliCommandMetadata = {
25
+ name: "terminal",
26
+ usage: "centrs terminal <router> [flags]",
27
+ summary: "Open an interactive RouterOS console (ssh or mac-telnet).",
28
+ options: [
29
+ {
30
+ flag: "--via",
31
+ valueName: "<method>",
32
+ description:
33
+ "Transport: `ssh` (host target, default) or `mac-telnet` (MAC target, default for a MAC). rest/native have no terminal capability.",
34
+ },
35
+ {
36
+ flag: "--host",
37
+ valueName: "<host>",
38
+ description:
39
+ "Override the target host (ssh) / UDP delivery host (mac-telnet, default L2 broadcast).",
40
+ },
41
+ {
42
+ flag: "--port",
43
+ valueName: "<port>",
44
+ description:
45
+ "Override the port: ssh default 22, mac-telnet default 20561.",
46
+ },
47
+ {
48
+ flag: "--username / --user / -u",
49
+ valueName: "<name>",
50
+ description:
51
+ "RouterOS username (aliases --user, -u). Falls back to CENTRS_USERNAME.",
52
+ },
53
+ {
54
+ flag: "--password",
55
+ valueName: "<secret>",
56
+ description: "RouterOS password. Falls back to CENTRS_PASSWORD.",
57
+ },
58
+ {
59
+ flag: "--source-mac",
60
+ valueName: "<mac>",
61
+ description:
62
+ "mac-telnet: explicit in-packet source MAC (overrides egress resolution).",
63
+ },
64
+ {
65
+ flag: "--ssh-key",
66
+ valueName: "<path>",
67
+ description:
68
+ "`--via ssh`: explicit private-key path. Falls back to `CENTRS_SSH_KEY` / the `ssh-agent`.",
69
+ },
70
+ {
71
+ flag: "--insecure",
72
+ description:
73
+ "`--via ssh`: disable host-key verification (accepts changed/impersonated keys). Default verifies.",
74
+ },
75
+ {
76
+ flag: "--cdb-file",
77
+ valueName: "<path>",
78
+ description: "Read target credentials from a WinBox CDB file.",
79
+ },
80
+ {
81
+ flag: "--cdb-password",
82
+ valueName: "<password>",
83
+ description: "Decrypt an encrypted WinBox CDB file.",
84
+ },
85
+ {
86
+ flag: "--resolve",
87
+ valueName: "<none|arp>",
88
+ description:
89
+ "`--via ssh` only: turn a MAC target into an IP. CDB-first; `arp` opts into the host ARP cache (default none). The mac-telnet default ignores it.",
90
+ },
91
+ {
92
+ flag: "--format",
93
+ valueName: "<text|json|yaml>",
94
+ description:
95
+ "Error-envelope format on failure. --json / --yaml shortcuts.",
96
+ },
97
+ { flag: "--json", description: "Shortcut for --format json." },
98
+ { flag: "--yaml", description: "Shortcut for --format yaml." },
99
+ { flag: "--verbose", description: "Verbose error output." },
100
+ ],
101
+ };
102
+
103
+ /** A `TerminalIo` backed by the real process stdin/stdout/SIGWINCH. */
104
+ function processTerminalIo(): TerminalIo {
105
+ const stdin = process.stdin;
106
+ const stdout = process.stdout;
107
+ const isInteractive = Boolean(stdin.isTTY && stdout.isTTY);
108
+ let dataListener: ((chunk: Buffer) => void) | undefined;
109
+ let endListener: (() => void) | undefined;
110
+ let resizeListener: (() => void) | undefined;
111
+ return {
112
+ isInteractive,
113
+ size() {
114
+ return { rows: stdout.rows ?? 9999, cols: stdout.columns ?? 512 };
115
+ },
116
+ writeOutput(bytes) {
117
+ stdout.write(bytes);
118
+ },
119
+ readInput(onChunk, onEnd) {
120
+ if (isInteractive && typeof stdin.setRawMode === "function") {
121
+ stdin.setRawMode(true);
122
+ }
123
+ dataListener = (chunk: Buffer) => onChunk(new Uint8Array(chunk));
124
+ endListener = onEnd;
125
+ stdin.on("data", dataListener);
126
+ stdin.on("end", endListener);
127
+ stdin.resume();
128
+ },
129
+ onResize(listener) {
130
+ resizeListener = listener;
131
+ process.on("SIGWINCH", listener);
132
+ },
133
+ cleanup() {
134
+ if (dataListener) stdin.off("data", dataListener);
135
+ if (endListener) stdin.off("end", endListener);
136
+ if (resizeListener) process.off("SIGWINCH", resizeListener);
137
+ if (isInteractive && typeof stdin.setRawMode === "function") {
138
+ try {
139
+ stdin.setRawMode(false);
140
+ } catch {
141
+ /* tty already restored / detached */
142
+ }
143
+ }
144
+ stdin.pause();
145
+ },
146
+ };
147
+ }
148
+
149
+ export async function runTerminalCli(args: readonly string[]): Promise<number> {
150
+ let request: TerminalRequest | undefined;
151
+ try {
152
+ const parsed = parseTerminalCliArgs(args);
153
+ if (parsed.help) {
154
+ console.log(renderCommandHelp(describeCentrs(), terminalCommand));
155
+ return 0;
156
+ }
157
+ request = parsed.request;
158
+ return await runTerminal(request, processTerminalIo());
159
+ } catch (error) {
160
+ // Render errors from `args` and the (credential-free) error only — never the
161
+ // parsed `request`, which holds the raw password for auth. CodeQL still
162
+ // reports js/clear-text-logging on any CLI error render (it follows the
163
+ // password parsed earlier in this function); the password reaches neither the
164
+ // error nor these renderers, so the alert is dismissed as a false positive
165
+ // repo-wide — see the matching dismissals on src/cli/{transfer,execute}.ts.
166
+ const format = inferRequestedFormat(args);
167
+ if (format === "json" || format === "yaml") {
168
+ const envelope = buildTerminalErrorEnvelope(error);
169
+ console.error(
170
+ format === "yaml" ? toYaml(envelope) : JSON.stringify(envelope),
171
+ );
172
+ } else {
173
+ console.error(
174
+ formatCentrsErrorText(
175
+ asCentrsError(error, {
176
+ code: "input/invalid-command",
177
+ summary: error instanceof Error ? error.message : String(error),
178
+ remediation:
179
+ "Use `centrs terminal --help` for the supported command shape and flags.",
180
+ }),
181
+ { verbose: args.includes("--verbose") },
182
+ ),
183
+ );
184
+ }
185
+ return 1;
186
+ }
187
+ }
188
+
189
+ interface ParsedTerminal {
190
+ help?: boolean;
191
+ request: TerminalRequest;
192
+ }
193
+
194
+ function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
195
+ const flags: TerminalRequest = {};
196
+ const positional: string[] = [];
197
+ let help = false;
198
+
199
+ for (let index = 0; index < args.length; index += 1) {
200
+ const arg = args[index];
201
+ if (arg === undefined) {
202
+ continue;
203
+ }
204
+ switch (arg) {
205
+ case "--help":
206
+ case "-h":
207
+ help = true;
208
+ break;
209
+ case "--via":
210
+ flags.via = expectValue(args, ++index, arg);
211
+ break;
212
+ case "--host":
213
+ flags.host = expectValue(args, ++index, arg);
214
+ break;
215
+ case "--port":
216
+ flags.port = Number.parseInt(expectValue(args, ++index, arg), 10);
217
+ break;
218
+ case "--user":
219
+ case "-u":
220
+ case "--username":
221
+ flags.username = expectValue(args, ++index, arg);
222
+ break;
223
+ case "--password":
224
+ flags.password = expectValue(args, ++index, arg);
225
+ break;
226
+ case "--source-mac":
227
+ flags.sourceMac = expectValue(args, ++index, arg);
228
+ break;
229
+ case "--ssh-key":
230
+ flags.sshKey = expectValue(args, ++index, arg);
231
+ break;
232
+ case "--insecure":
233
+ flags.insecure = true;
234
+ break;
235
+ case "--cdb-file":
236
+ flags.cdbFile = expectValue(args, ++index, arg);
237
+ break;
238
+ case "--cdb-password":
239
+ flags.cdbPassword = expectValue(args, ++index, arg);
240
+ break;
241
+ case "--resolve":
242
+ flags.resolve = expectValue(args, ++index, arg);
243
+ break;
244
+ case "--format":
245
+ flags.format = expectValue(args, ++index, arg);
246
+ break;
247
+ case "--json":
248
+ flags.format = "json";
249
+ break;
250
+ case "--yaml":
251
+ flags.format = "yaml";
252
+ break;
253
+ case "--verbose":
254
+ flags.verbose = true;
255
+ break;
256
+ default:
257
+ if (arg.startsWith("-")) {
258
+ throw asCentrsError(new Error(`Unknown terminal flag: ${arg}`), {
259
+ code: "input/invalid-command",
260
+ summary: `Unknown terminal flag: ${arg}`,
261
+ remediation:
262
+ "Remove the flag or run `centrs terminal --help` for the supported options.",
263
+ });
264
+ }
265
+ positional.push(arg);
266
+ break;
267
+ }
268
+ }
269
+
270
+ if (help) {
271
+ return { help: true, request: {} };
272
+ }
273
+ const targetInput = positional[0];
274
+ if (!targetInput) {
275
+ throw asCentrsError(new Error("terminal requires a <router> target."), {
276
+ code: "input/invalid-command",
277
+ summary: "`centrs terminal` requires a <router> target.",
278
+ remediation:
279
+ "Pass the device MAC (or a CDB identity/ip) as the first argument; run `centrs terminal --help` for the shape.",
280
+ });
281
+ }
282
+ flags.targetInput = targetInput;
283
+ return { request: flags };
284
+ }
285
+
286
+ function inferRequestedFormat(
287
+ args: readonly string[],
288
+ requestFormat?: string,
289
+ ): "text" | "json" | "yaml" {
290
+ const candidate =
291
+ requestFormat ?? formatFromArgs(args) ?? process.env["CENTRS_FORMAT"];
292
+ if (candidate === "json" || candidate === "yaml" || candidate === "text") {
293
+ return candidate;
294
+ }
295
+ return "text";
296
+ }
297
+
298
+ function formatFromArgs(args: readonly string[]): string | undefined {
299
+ if (args.includes("--json")) return "json";
300
+ if (args.includes("--yaml")) return "yaml";
301
+ const index = args.indexOf("--format");
302
+ return index !== -1 ? args[index + 1] : undefined;
303
+ }