@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,444 @@
1
+ /**
2
+ * `terminal` — open an interactive RouterOS console (v1: mac-telnet only).
3
+ *
4
+ * Unlike `execute`, which runs one command and captures its output, `terminal`
5
+ * is a **raw passthrough**: after the shared `MacTelnetConsole` logs in and primes
6
+ * the prompt, input bytes are forwarded to the device and device output streams
7
+ * straight back out. The connection (resolver → route → UDP transport → console)
8
+ * is the exact path `execute / mac-telnet` proved; only the relay on top is new.
9
+ *
10
+ * Two input modes, chosen by whether stdin is a TTY (see {@link TerminalIo}):
11
+ * - interactive: raw-mode keystrokes, real terminal size, `SIGWINCH` resizes;
12
+ * - batch (piped stdin): forward bytes, then close on EOF after an output drain.
13
+ *
14
+ * The stream/exit contract: device bytes go to stdout, errors/summary to stderr,
15
+ * exit 0 on a clean close. A pre-stream failure (no terminal capability, auth,
16
+ * unresolved route) throws before any byte is emitted, so the CLI can render a
17
+ * clean error envelope. See `commands/terminal/README.md` / `examples.md`.
18
+ */
19
+
20
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
21
+ import {
22
+ createUdpMacTelnetTransport,
23
+ isBroadcastHost,
24
+ type MacTelnetTransport,
25
+ parseMac,
26
+ resolveMacTelnetRoute,
27
+ } from "./protocols/mac-telnet.ts";
28
+ import { MacTelnetConsole } from "./protocols/mac-telnet-console.ts";
29
+ import { sshCommonOptions, sshUserHost } from "./protocols/ssh.ts";
30
+ import {
31
+ type CdbResolution,
32
+ isIpTransport,
33
+ isMacAddress,
34
+ parseResolvePolicy,
35
+ type ResolvedAuth,
36
+ type ResolvedTarget,
37
+ resolveAuth,
38
+ resolveBooleanSetting,
39
+ resolveCdb,
40
+ resolveMacTarget,
41
+ resolveStringSetting,
42
+ resolveTarget,
43
+ } from "./resolver/index.ts";
44
+
45
+ /** Prime budget (login + license + the ~10s mac-telnet negotiation stall). */
46
+ const PRIME_TIMEOUT_MS = 30_000;
47
+ /** Per-session command/idle budget. */
48
+ const COMMAND_TIMEOUT_MS = 15_000;
49
+ /** Batch mode: close after this much output quiescence following stdin EOF. */
50
+ const DRAIN_IDLE_MS = 500;
51
+
52
+ export interface TerminalRequest {
53
+ /**
54
+ * Device handle: a MAC (resolves to the L2 console over mac-telnet), or a
55
+ * host / IP / CDB identity (the IP-level console over ssh).
56
+ */
57
+ targetInput?: string;
58
+ /**
59
+ * Transport. Defaults to `mac-telnet` for a MAC target and `ssh` for any other
60
+ * (host / IP / CDB identity); rest/native have no terminal capability.
61
+ */
62
+ via?: string;
63
+ /** UDP delivery host override (defaults to the L2 broadcast route discovery). */
64
+ host?: string;
65
+ /** UDP delivery port override (defaults to 20561). */
66
+ port?: number;
67
+ /**
68
+ * MAC→IP resolution policy for the IP transport (`--via ssh`): `none`
69
+ * (default; CDB-first, then error) or `arp` (opt into host ARP). Ignored by
70
+ * the L2 default (`mac-telnet`), which addresses the MAC directly.
71
+ */
72
+ resolve?: string;
73
+ username?: string;
74
+ password?: string;
75
+ /** Explicit in-packet source MAC (overrides egress-MAC resolution; mac-telnet). */
76
+ sourceMac?: string;
77
+ /** `--via ssh`: private-key path (path only; agent / ~/.ssh used if unset). */
78
+ sshKey?: string;
79
+ /** `--via ssh`: disable SSH host-key verification (accepts changed keys). */
80
+ insecure?: boolean;
81
+ cdbFile?: string;
82
+ cdbPassword?: string;
83
+ format?: string;
84
+ verbose?: boolean;
85
+ }
86
+
87
+ /**
88
+ * The stdio/terminal seam `terminal` drives, injectable so the relay is testable
89
+ * without a real TTY. The CLI wires `process.*`; a test can pass scripted streams.
90
+ */
91
+ export interface TerminalIo {
92
+ /** True when both stdin and stdout are TTYs (raw-mode interactive relay). */
93
+ isInteractive: boolean;
94
+ /** Current terminal size reported to the device. */
95
+ size(): { rows: number; cols: number };
96
+ /** Write raw device output bytes to the user's terminal. */
97
+ writeOutput(bytes: Uint8Array): void;
98
+ /** Start reading input; `onChunk` per chunk, `onEnd` once at EOF. */
99
+ readInput(onChunk: (bytes: Uint8Array) => void, onEnd: () => void): void;
100
+ /** Register a window-resize listener (interactive only). */
101
+ onResize?(listener: () => void): void;
102
+ /** Restore terminal state (raw mode off, listeners removed). */
103
+ cleanup(): void;
104
+ }
105
+
106
+ interface ResolvedTerminal {
107
+ via: string;
108
+ target: ResolvedTarget;
109
+ auth: ResolvedAuth;
110
+ sourceMac?: string;
111
+ insecure: boolean;
112
+ warnings: CdbResolution["warnings"];
113
+ }
114
+
115
+ /** Reject any transport without a terminal capability (mac-telnet + ssh). */
116
+ function gateTerminalVia(via: string): void {
117
+ if (via === "mac-telnet" || via === "ssh") {
118
+ return;
119
+ }
120
+ if (via === "rest-api" || via === "native-api") {
121
+ throw new CentrsError({
122
+ code: "transport/capability-unsupported",
123
+ summary: `${via} has no terminal capability.`,
124
+ remediation:
125
+ "terminal is a console transport: use `--via ssh` (host target) or `--via mac-telnet` (MAC target). For structured reads use `retrieve --via rest-api`/`native-api`.",
126
+ context: { via, command: "terminal" },
127
+ });
128
+ }
129
+ throw new CentrsError({
130
+ code: "settings/invalid-via",
131
+ summary: `Unsupported terminal transport: ${via}.`,
132
+ remediation: "terminal supports `--via mac-telnet` or `--via ssh`.",
133
+ context: { via },
134
+ });
135
+ }
136
+
137
+ /** Resolve a terminal request to a mac-telnet route; reject other transports. */
138
+ export async function resolveTerminalRequest(
139
+ request: TerminalRequest,
140
+ env: Record<string, string | undefined> = Bun.env,
141
+ ): Promise<ResolvedTerminal> {
142
+ // Gate the transport BEFORE any CDB load: an explicit unsupported `--via`
143
+ // should fail fast and hermetically, without a device-registry lookup for a
144
+ // request we will reject anyway (an unmatched target would otherwise throw a
145
+ // `cdb/not-found-target` that masks the real "no terminal capability" reason).
146
+ const preliminaryVia = request.via ?? env["CENTRS_VIA"];
147
+ if (preliminaryVia !== undefined) {
148
+ gateTerminalVia(preliminaryVia);
149
+ }
150
+ const cdb = await resolveCdb(
151
+ {
152
+ targetInput: request.targetInput,
153
+ cdbFile: request.cdbFile,
154
+ cdbPassword: request.cdbPassword,
155
+ },
156
+ env,
157
+ );
158
+ // A MAC target defaults to the L2 console (mac-telnet); any other target
159
+ // (host / IP / CDB identity) defaults to ssh — the IP-level terminal transport.
160
+ const defaultVia = isMacAddress(request.targetInput ?? "")
161
+ ? "mac-telnet"
162
+ : "ssh";
163
+ const via = resolveStringSetting(
164
+ request.via,
165
+ env,
166
+ "CENTRS_VIA",
167
+ defaultVia,
168
+ "via",
169
+ undefined,
170
+ cdb?.overrides.via,
171
+ );
172
+ const viaValue = via?.value ?? defaultVia;
173
+ // Re-gate on the fully resolved value to catch a CDB comment-kv `via=` override.
174
+ gateTerminalVia(viaValue);
175
+ // A MAC target over the IP transport (`--via ssh`) needs an IP. Resolve it
176
+ // CDB-first (the matched record's `target` already wins below); only when no
177
+ // record matches does this throw `target/mac-unresolved` — unless the caller
178
+ // opted into host ARP with `--resolve arp`. The L2 default (mac-telnet)
179
+ // addresses the MAC directly, so it never resolves (NO silent ARP).
180
+ const macResolution = isIpTransport(viaValue)
181
+ ? await resolveMacTarget({
182
+ host: request.host,
183
+ targetInput: request.targetInput,
184
+ cdbTarget: cdb?.target,
185
+ env,
186
+ policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
187
+ operation: "terminal",
188
+ })
189
+ : undefined;
190
+ const target = resolveTarget(
191
+ {
192
+ targetInput: request.targetInput,
193
+ host: request.host,
194
+ port: request.port,
195
+ macResolution,
196
+ },
197
+ env,
198
+ viaValue === "ssh" ? "ssh" : "mac-telnet",
199
+ cdb,
200
+ );
201
+ const auth = resolveAuth(
202
+ {
203
+ username: request.username,
204
+ password: request.password,
205
+ sshKey: request.sshKey,
206
+ },
207
+ env,
208
+ cdb,
209
+ );
210
+ const insecure = resolveBooleanSetting(
211
+ request.insecure,
212
+ env,
213
+ "CENTRS_INSECURE",
214
+ false,
215
+ "insecure",
216
+ cdb?.overrides.insecure,
217
+ );
218
+ return {
219
+ via: viaValue,
220
+ target,
221
+ auth,
222
+ sourceMac: request.sourceMac,
223
+ insecure: insecure.value,
224
+ warnings: cdb?.warnings ?? [],
225
+ };
226
+ }
227
+
228
+ /** Build the UDP transport + console for a resolved mac-telnet terminal and open it. */
229
+ async function connectTerminalConsole(
230
+ resolved: ResolvedTerminal,
231
+ io: TerminalIo,
232
+ ): Promise<{ console: MacTelnetConsole; transport: MacTelnetTransport }> {
233
+ if (!resolved.target.mac) {
234
+ throw new CentrsError({
235
+ code: "target/mac-required",
236
+ summary: "terminal over mac-telnet needs a MAC address target.",
237
+ remediation: "Pass the device MAC (aa:bb:cc:dd:ee:ff).",
238
+ });
239
+ }
240
+ const destinationMac = parseMac(resolved.target.mac);
241
+ const { sourceMac, host } = await resolveMacTelnetRoute({
242
+ destinationMac,
243
+ host: resolved.target.host,
244
+ port: resolved.target.port,
245
+ timeoutMs: COMMAND_TIMEOUT_MS,
246
+ explicitSourceMac: resolved.sourceMac
247
+ ? parseMac(resolved.sourceMac)
248
+ : undefined,
249
+ });
250
+ const transport = createUdpMacTelnetTransport({
251
+ host,
252
+ port: resolved.target.port,
253
+ broadcast: isBroadcastHost(host),
254
+ });
255
+ await transport.ready();
256
+ // Interactive: report the real terminal size so the device neither wraps nor
257
+ // paginates. Batch: keep the console's wide capture defaults (no TTY size).
258
+ const size = io.isInteractive ? io.size() : undefined;
259
+ const console = new MacTelnetConsole({
260
+ sink: transport,
261
+ sourceMac,
262
+ destinationMac,
263
+ username: resolved.auth.username ?? "",
264
+ password: resolved.auth.password,
265
+ primeTimeoutMs: PRIME_TIMEOUT_MS,
266
+ commandTimeoutMs: COMMAND_TIMEOUT_MS,
267
+ ...(size ? { rows: size.rows, cols: size.cols } : {}),
268
+ });
269
+ transport.onMessage((bytes) => console.handlePacket(bytes));
270
+ await console.open();
271
+ return { console, transport };
272
+ }
273
+
274
+ /**
275
+ * Run an interactive terminal session and resolve with the exit code. mac-telnet
276
+ * drives the in-process console relay over {@link TerminalIo} (returns 0 on a
277
+ * clean close; throws before any output on a pre-stream failure). ssh delegates to
278
+ * the host `ssh` client with inherited stdio and returns its exit code. A
279
+ * non-terminal transport throws `transport/capability-unsupported` from the gate.
280
+ */
281
+ export async function runTerminal(
282
+ request: TerminalRequest,
283
+ io: TerminalIo,
284
+ env: Record<string, string | undefined> = Bun.env,
285
+ ): Promise<number> {
286
+ const resolved = await resolveTerminalRequest(request, env);
287
+ if (resolved.via === "ssh") {
288
+ return runTerminalSsh(resolved);
289
+ }
290
+ const { console: cons, transport } = await connectTerminalConsole(
291
+ resolved,
292
+ io,
293
+ );
294
+
295
+ await new Promise<void>((resolve, reject) => {
296
+ let settled = false;
297
+ let lastOutputAt = Date.now();
298
+ let sawOutput = false;
299
+ let drainTimer: ReturnType<typeof setInterval> | undefined;
300
+ let drainCap: ReturnType<typeof setTimeout> | undefined;
301
+
302
+ const finish = (error?: CentrsError): void => {
303
+ if (settled) {
304
+ return;
305
+ }
306
+ settled = true;
307
+ if (drainTimer) clearInterval(drainTimer);
308
+ if (drainCap) clearTimeout(drainCap);
309
+ cons.close();
310
+ transport.close();
311
+ io.cleanup();
312
+ if (error) {
313
+ reject(error);
314
+ } else {
315
+ resolve();
316
+ }
317
+ };
318
+
319
+ cons.onClosed((error) => finish(error));
320
+ cons.attachInteractive(
321
+ (bytes) => {
322
+ lastOutputAt = Date.now();
323
+ sawOutput = true;
324
+ io.writeOutput(bytes);
325
+ },
326
+ { answerProbe: !io.isInteractive },
327
+ );
328
+ io.onResize?.(() => {
329
+ const { rows, cols } = io.size();
330
+ cons.reportSize(rows, cols);
331
+ });
332
+
333
+ const onEnd = (): void => {
334
+ if (io.isInteractive) {
335
+ // A TTY EOF (the stream closing) ends the session.
336
+ finish();
337
+ return;
338
+ }
339
+ // Batch: stdin is exhausted, but the device may still be answering the
340
+ // last line — and stdin EOF typically beats the first response byte. Only
341
+ // close once we have seen the response *and* it has been quiet for
342
+ // DRAIN_IDLE_MS; `sawOutput` keeps the stale prime-prompt timestamp from
343
+ // closing us early. A hard cap covers a command that prints nothing.
344
+ drainTimer = setInterval(() => {
345
+ if (sawOutput && Date.now() - lastOutputAt >= DRAIN_IDLE_MS) {
346
+ finish();
347
+ }
348
+ }, 50);
349
+ drainTimer.unref?.();
350
+ drainCap = setTimeout(() => finish(), COMMAND_TIMEOUT_MS);
351
+ drainCap.unref?.();
352
+ };
353
+
354
+ io.readInput((bytes) => cons.write(bytes), onEnd);
355
+ });
356
+ return 0;
357
+ }
358
+
359
+ /**
360
+ * Build the `ssh` argv for an interactive RouterOS terminal: `ssh -p <port>
361
+ * <key/trust options> user@host` with **no** command (so it opens the console)
362
+ * and **no** `-t` (RouterOS grants no PTY; forcing one with `-tt` hangs, and a
363
+ * real TTY makes the host `ssh` request a PTY on its own). `interactive: true`
364
+ * drops `BatchMode=yes` (unlike the batch sftp/execute clients), so the host
365
+ * `ssh` may prompt on the inherited TTY for an encrypted key's passphrase or a
366
+ * password — centrs does not forward `--password`, as OpenSSH takes no password
367
+ * on the argv. Exported for tests.
368
+ */
369
+ export function buildSshTerminalArgv(resolved: ResolvedTerminal): string[] {
370
+ const conn = {
371
+ host: resolved.target.host,
372
+ port: resolved.target.port,
373
+ username: resolved.auth.username,
374
+ sshKey: resolved.auth.sshKey,
375
+ insecure: resolved.insecure,
376
+ interactive: true,
377
+ timeoutMs: PRIME_TIMEOUT_MS,
378
+ };
379
+ return [
380
+ "ssh",
381
+ "-p",
382
+ String(resolved.target.port),
383
+ ...sshCommonOptions(conn),
384
+ sshUserHost(conn),
385
+ ];
386
+ }
387
+
388
+ /**
389
+ * Terminal over SSH: exec the host `ssh` with inherited stdio, so the OS relays
390
+ * the (already clean, no-PTY) RouterOS console — no screen emulation needed. The
391
+ * interactive TTY, raw mode, and signals are the inherited terminal's; centrs's
392
+ * value is resolving the target/key/trust and building the argv. Returns ssh's
393
+ * exit code (a no-PTY console closed by EOF can exit non-zero — that is the
394
+ * device/ssh's result, not a centrs failure).
395
+ */
396
+ async function runTerminalSsh(resolved: ResolvedTerminal): Promise<number> {
397
+ const argv = buildSshTerminalArgv(resolved);
398
+ let proc: ReturnType<typeof Bun.spawn>;
399
+ try {
400
+ proc = Bun.spawn(argv, {
401
+ stdin: "inherit",
402
+ stdout: "inherit",
403
+ stderr: "inherit",
404
+ });
405
+ } catch (cause) {
406
+ throw new CentrsError({
407
+ code: "transport/local-tool-missing",
408
+ summary: "Cannot launch the host `ssh` client for the terminal.",
409
+ remediation:
410
+ "Install an OpenSSH client so `ssh` is on PATH (macOS ships it; Debian/Ubuntu: `openssh-client`).",
411
+ context: { binary: "ssh", via: "ssh" },
412
+ cause,
413
+ });
414
+ }
415
+ return await proc.exited;
416
+ }
417
+
418
+ export interface TerminalErrorEnvelope {
419
+ ok: false;
420
+ error: ReturnType<typeof serializeCentrsError>;
421
+ warnings: [];
422
+ tips: [];
423
+ }
424
+
425
+ /** Wrap a pre-stream failure as the standard error envelope (rendered to stderr). */
426
+ export function buildTerminalErrorEnvelope(
427
+ error: unknown,
428
+ ): TerminalErrorEnvelope {
429
+ const centrs =
430
+ error instanceof CentrsError
431
+ ? error
432
+ : new CentrsError({
433
+ code: "internal/unhandled",
434
+ summary: error instanceof Error ? error.message : String(error),
435
+ remediation:
436
+ "This is a centrs bug; re-run with --verbose and file an issue with the printed code.",
437
+ });
438
+ return {
439
+ ok: false,
440
+ error: serializeCentrsError(centrs),
441
+ warnings: [],
442
+ tips: [],
443
+ };
444
+ }