@opentui/ssh 0.0.0-20260612-c3be6d8c
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.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/index.js +988 -0
- package/index.js.map +20 -0
- package/package.json +31 -0
- package/src/auth.d.ts +97 -0
- package/src/banner.d.ts +14 -0
- package/src/bridge.d.ts +50 -0
- package/src/connection.d.ts +27 -0
- package/src/errors.d.ts +35 -0
- package/src/index.d.ts +4 -0
- package/src/keys.d.ts +18 -0
- package/src/logging.d.ts +40 -0
- package/src/run-session.d.ts +25 -0
- package/src/runtime.d.ts +38 -0
- package/src/safe.d.ts +23 -0
- package/src/server.d.ts +32 -0
- package/src/types.d.ts +294 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 opentui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# @opentui/ssh
|
|
2
|
+
|
|
3
|
+
Serve OpenTUI apps over SSH.
|
|
4
|
+
|
|
5
|
+
`@opentui/ssh` turns an incoming SSH session into a fully-wired OpenTUI
|
|
6
|
+
[`CliRenderer`](../core) whose input/output is the SSH channel and whose
|
|
7
|
+
dimensions track the client's PTY. What you render onto it is up to you — the
|
|
8
|
+
package is **renderer-agnostic**: it depends only on `@opentui/core`, never on
|
|
9
|
+
`@opentui/react` or `@opentui/solid`, so the same server works with all three.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createServer } from "@opentui/ssh"
|
|
13
|
+
import { BoxRenderable, TextRenderable } from "@opentui/core"
|
|
14
|
+
|
|
15
|
+
const server = createServer({
|
|
16
|
+
hostKey: { path: "./host_key" }, // auto-generated & persisted on first run
|
|
17
|
+
auth: { publicKey: "any" }, // open, but every client gets an identity
|
|
18
|
+
}).serve((session) => {
|
|
19
|
+
const { renderer, identity } = session
|
|
20
|
+
const box = new BoxRenderable(renderer, { width: "100%", height: "100%", border: true })
|
|
21
|
+
box.add(new TextRenderable(renderer, { content: `Hello, ${identity.username}!` }))
|
|
22
|
+
renderer.root.add(box)
|
|
23
|
+
// the renderer is destroyed for you on disconnect — wire onClose only for your own cleanup
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await server.listen(2222)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
ssh -p 2222 localhost
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
bun add @opentui/ssh
|
|
37
|
+
# or
|
|
38
|
+
npm install @opentui/ssh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`@opentui/core` is a peer dependency. Supported runtimes are Bun ≥ 1.3.0 and
|
|
42
|
+
Node.js 26.3.0. CI runs the SSH integration suite with Bun on macOS, Linux,
|
|
43
|
+
and Windows, and installs, imports, starts, and closes the packed ESM package
|
|
44
|
+
with Node.js 26.3.0.
|
|
45
|
+
|
|
46
|
+
## The shape: `createServer(config).serve(handler)`
|
|
47
|
+
|
|
48
|
+
Static setup goes in the `createServer({...})` config object; cross-cutting
|
|
49
|
+
concerns are layered on with `.use()`; **`serve(handler)` seals the chain with
|
|
50
|
+
the per-session handler and returns a startable server.** The handler lives on
|
|
51
|
+
`serve()` — not the config — so the builder accumulates the typed `context` each
|
|
52
|
+
`use()` contributes and flows it into the handler, and a handler-less server is a
|
|
53
|
+
compile error (the builder has no `listen()` until you `serve`).
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const server = createServer({
|
|
57
|
+
// optional, all with sensible defaults:
|
|
58
|
+
// auth, hostKey, idleTimeout, maxTimeout, limits, startupBanner, onError
|
|
59
|
+
})
|
|
60
|
+
.use(logging()) // optional middleware (see "Middleware" below)
|
|
61
|
+
.serve((session) => {
|
|
62
|
+
/* mount your app on session.renderer — REQUIRED */
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await server.listen() // defaults to port 2222 on 127.0.0.1; pass (port, host) to change
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`listen(port = 2222, host = "127.0.0.1")` returns `{ host, port, fingerprints }`.
|
|
69
|
+
Pass `0` for an ephemeral port. Pass a host like `"0.0.0.0"` or `"::"` to listen
|
|
70
|
+
on all interfaces, which is common in containers. Listening on a host other than
|
|
71
|
+
`localhost`, `127.0.0.1`, or `::1` with no auth logs a warning (it never throws — an
|
|
72
|
+
intentionally exposed TUI is legitimate).
|
|
73
|
+
|
|
74
|
+
### `Session`
|
|
75
|
+
|
|
76
|
+
The handler you pass to `serve()` receives a `Session` with
|
|
77
|
+
the live `renderer`. Middleware receive a `MiddlewareSession` **without** `renderer`
|
|
78
|
+
(it's the app's resource, created only once the chain authorizes the session — so
|
|
79
|
+
a gating middleware that declines never spins one up). Everything else is shared:
|
|
80
|
+
|
|
81
|
+
| Field | What it is |
|
|
82
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `renderer` | A `CliRenderer` bound to this SSH channel, sized to the client PTY. **Handler-only** — present on the handler's `Session`, absent on a middleware's `MiddlewareSession` (a middleware must `next()` first). Destroyed for you on disconnect. |
|
|
84
|
+
| `identity` | Who connected and how — **narrowed to your configured auth** (see below). |
|
|
85
|
+
| `context` | Per-session bag of the typed fields upstream middleware contributed via `next({...})`; `{}` with no middleware. |
|
|
86
|
+
| `term` | The client's `TERM` (e.g. `"xterm-256color"`). |
|
|
87
|
+
| `cols` / `rows` | Current terminal size; updated on resize. |
|
|
88
|
+
| `hasPty` | Whether the client requested a PTY; use it for `requirePty`-style middleware. |
|
|
89
|
+
| `remoteAddress` | `{ address, port }` client socket endpoint for logging, rate limiting, and policy. |
|
|
90
|
+
| `onResize(cb)` | Fires on client resize; the renderer is already resized for you. |
|
|
91
|
+
| `onClose(cb)` | Fires when the client disconnects; do your OWN per-session cleanup here (the renderer is torn down for you). |
|
|
92
|
+
| `write(data)` | Raw bytes straight to the client, bypassing the renderer's frame diffing — the escape hatch for terminal control the renderer doesn't model (OSC 52 clipboard, window title, a bell). |
|
|
93
|
+
| `end()` | Force-close just this session. |
|
|
94
|
+
|
|
95
|
+
## The three hand-offs
|
|
96
|
+
|
|
97
|
+
Because the package's job ends at producing a `CliRenderer`, you mount whatever
|
|
98
|
+
front-end you like onto `session.renderer`. Runnable versions of all three live
|
|
99
|
+
in [`examples/`](./examples).
|
|
100
|
+
|
|
101
|
+
### Imperative (`@opentui/core`)
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
createServer().serve((session) => {
|
|
105
|
+
const box = new BoxRenderable(session.renderer, { border: true })
|
|
106
|
+
session.renderer.root.add(box)
|
|
107
|
+
// no teardown to wire — the renderer is destroyed for you on disconnect
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### React (`@opentui/react`)
|
|
112
|
+
|
|
113
|
+
`createRoot` adopts the existing renderer as-is — see
|
|
114
|
+
[`examples/react.tsx`](./examples/react.tsx).
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { createRoot } from "@opentui/react"
|
|
118
|
+
|
|
119
|
+
createServer().serve((session) => {
|
|
120
|
+
const root = createRoot(session.renderer)
|
|
121
|
+
root.render(<App name={session.identity.username} />)
|
|
122
|
+
session.onClose(() => root.unmount()) // your own teardown
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Solid (`@opentui/solid`)
|
|
127
|
+
|
|
128
|
+
`render(node, renderer)` checks `instanceof CliRenderer` and **adopts** the
|
|
129
|
+
renderer you pass — so the app draws onto the SSH channel, not the host terminal.
|
|
130
|
+
See [`examples/solid.tsx`](./examples/solid.tsx).
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { render } from "@opentui/solid"
|
|
134
|
+
|
|
135
|
+
createServer().serve(async (session) => {
|
|
136
|
+
// Solid disposes its root when the renderer is destroyed — nothing to wire.
|
|
137
|
+
await render(() => <App name={session.identity.username} />, session.renderer)
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
> `@opentui/react` / `@opentui/solid` are **not** runtime dependencies of this
|
|
142
|
+
> package — the framework examples use workspace dev dependencies only to
|
|
143
|
+
> demonstrate the hand-off. Run the Solid example with
|
|
144
|
+
> `bun run packages/ssh/examples/solid.tsx`; its launcher registers the required
|
|
145
|
+
> JSX transform before loading the app.
|
|
146
|
+
|
|
147
|
+
## Auth & type-flowing identity
|
|
148
|
+
|
|
149
|
+
`createServer` infers the identity type from your `auth` config, so
|
|
150
|
+
`session.identity` is narrowed to exactly the methods you enabled — you can only
|
|
151
|
+
read a field you actually required. `auth` is optional and defaults to `"open"`
|
|
152
|
+
(no auth), so the getting-started snippet just works on localhost.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// publickey-only → fingerprint is guaranteed present
|
|
156
|
+
createServer({ auth: { publicKey: "any" } }).serve((s) => s.identity.fingerprint) // ✅ string, no null check
|
|
157
|
+
|
|
158
|
+
// publickey + password → a union; discriminate on .method
|
|
159
|
+
createServer({ auth: { publicKey: "any", password: checkPw } }).serve((s) => {
|
|
160
|
+
if (s.identity.method === "publickey") s.identity.fingerprint // ✅ narrowed
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Supported methods (mix freely; the server advertises exactly what you configure):
|
|
165
|
+
|
|
166
|
+
| Config | Behavior |
|
|
167
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
168
|
+
| `auth: "open"` (or omit) | Allow unauthenticated access. The default when `auth` is omitted; listening on `0.0.0.0`/`::` with it warns. |
|
|
169
|
+
| `publicKey: "any"` | Accept **and identify** any key. The signature is verified (proof of possession), then `identity.fingerprint` is set. |
|
|
170
|
+
| `publicKey: { allow: (ctx) => boolean }` | Your own allow/deny over `{ username, fingerprint, publicKey }`, run after the signature verifies. |
|
|
171
|
+
| `publicKey: { authorizedKeys: path \| string[] }` | Allowlist from plain public-key lines; blanks/comments allowed, OpenSSH options are not interpreted. |
|
|
172
|
+
| `publicKey: { authorizedKeys, allow }` | Both — OR-merged: admit if on the allowlist **or** `allow` returns true. |
|
|
173
|
+
| `password: (ctx) => boolean` | Password check over `{ username, password }`. |
|
|
174
|
+
| `keyboardInteractive: (ctx) => boolean` | Prompt/response flow. |
|
|
175
|
+
|
|
176
|
+
> Publickey auth is verified end-to-end: `@opentui/ssh` checks the client's
|
|
177
|
+
> signature itself (`ssh2` does not), so `publicKey: "any"` proves possession of
|
|
178
|
+
> the private key rather than trusting a claimed key.
|
|
179
|
+
|
|
180
|
+
For a restricted public-key server, see [`examples/authorized-keys.ts`](./examples/authorized-keys.ts).
|
|
181
|
+
|
|
182
|
+
## Middleware (`.use()`)
|
|
183
|
+
|
|
184
|
+
`.use()` wraps your handler with cross-cutting concerns — gating, enrichment,
|
|
185
|
+
logging. A middleware is an onion: `(session, next) => Handoff`. Call `next()` to
|
|
186
|
+
continue (or `next({ ... })` to contribute typed fields to `session.context`) and
|
|
187
|
+
**return that hand-off** — forgetting to is a compile error. To gate, call
|
|
188
|
+
`session.deny(reason)`, which throws to unwind the chain (you needn't return it).
|
|
189
|
+
|
|
190
|
+
1. **Registration order === execution order.** The _first_-registered middleware
|
|
191
|
+
is the _outermost_ link.
|
|
192
|
+
2. **`await next()` resolves when the session ends.** The handler is the innermost
|
|
193
|
+
link, wrapped so it doesn't resolve until disconnect — so a `try { return await
|
|
194
|
+
next() } finally { ... }` runs its cleanup as teardown.
|
|
195
|
+
3. **Contributions are inferred.** `next({ tier: "free" })` widens
|
|
196
|
+
`session.context` by `{ tier: string }` with no generic to declare; each `.use`
|
|
197
|
+
accumulates, so a later link reads earlier links' fields typed and the handler
|
|
198
|
+
reads the sum.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { createServer, type Middleware } from "@opentui/ssh"
|
|
202
|
+
|
|
203
|
+
// SETUP/TEARDOWN — author a reusable middleware by typing it as `Middleware`. Before
|
|
204
|
+
// next() is setup; the finally (after next() resolves at disconnect) is teardown.
|
|
205
|
+
const logging: Middleware = async (session, next) => {
|
|
206
|
+
const start = Date.now()
|
|
207
|
+
try {
|
|
208
|
+
return await next() // resolves at disconnect, to the accumulated context
|
|
209
|
+
} finally {
|
|
210
|
+
console.log(`${session.identity.username} stayed ${Date.now() - start}ms`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
createServer({ auth: "open" })
|
|
215
|
+
.use(logging)
|
|
216
|
+
// GATE — deny() throws to bounce; otherwise continue with next().
|
|
217
|
+
.use((s, next) => {
|
|
218
|
+
if (s.identity.username === "banned") s.deny("no entry")
|
|
219
|
+
return next()
|
|
220
|
+
})
|
|
221
|
+
// ENRICH — next({...}) contributes typed context the handler reads.
|
|
222
|
+
.use((_s, next) => next({ tier: "free" as const }))
|
|
223
|
+
.serve((s) => {
|
|
224
|
+
s.context.tier // "free" — typed, no cast
|
|
225
|
+
})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Those three patterns — **setup/teardown**, **gate**, **enrich** — cover almost
|
|
229
|
+
everything. Inline arrows need no annotation (their `identity`/`context` flow from
|
|
230
|
+
the builder); to name and reuse one, type it as `Middleware` (or
|
|
231
|
+
`MiddlewareFunction` when it must read upstream context). A gating
|
|
232
|
+
middleware's `deny()` runs before the handler, so the handler never runs **and the
|
|
233
|
+
renderer is never created** — the reason lands on the main screen and persists,
|
|
234
|
+
exactly what a rejection wants. (The renderer is the app's resource: middleware
|
|
235
|
+
see a `MiddlewareSession` without it; only the handler's `Session` has it.) See
|
|
236
|
+
[`examples/middleware.ts`](./examples/middleware.ts).
|
|
237
|
+
|
|
238
|
+
### Built-in: `logging`
|
|
239
|
+
|
|
240
|
+
`@opentui/ssh` ships one ready-made middleware. `logging()` is a setup/teardown
|
|
241
|
+
link that emits a `connect` event on entry and a `disconnect` (with duration) on
|
|
242
|
+
teardown. It is **pure observability** — it never reports errors, so `onError`
|
|
243
|
+
stays the single error sink; a throwing handler is logged as a normal disconnect
|
|
244
|
+
_and_ still flows to `onError`.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { createServer, logging } from "@opentui/ssh"
|
|
248
|
+
|
|
249
|
+
createServer({ auth: { publicKey: "any" } })
|
|
250
|
+
.use(logging()) // one line per event to console.log…
|
|
251
|
+
.use(logging({ log: (e) => metrics.record(e) })) // …or a structured sink
|
|
252
|
+
.serve((s) => mountApp(s.renderer))
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The `log` sink receives a `LogEvent` (`type`, `identity`, `remoteAddress`, `term`,
|
|
256
|
+
`cols`/`rows`, and `durationMs` on disconnect). Omit it for a one-line default.
|
|
257
|
+
|
|
258
|
+
## Host key
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
hostKey: {
|
|
262
|
+
path: "./host_key"
|
|
263
|
+
} // load if present; else generate ed25519 & persist (0600)
|
|
264
|
+
hostKey: {
|
|
265
|
+
pem: "..."
|
|
266
|
+
} // provide PEM directly
|
|
267
|
+
// omit entirely → ephemeral key, regenerated each start (fine for dev)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The first run with a `path` generates and saves an ed25519 key; `listen()` prints
|
|
271
|
+
its fingerprint so clients can verify it. When multiple PEMs are provided, every
|
|
272
|
+
fingerprint is returned and printed in the same order.
|
|
273
|
+
|
|
274
|
+
## Lifecycle, errors & shutdown
|
|
275
|
+
|
|
276
|
+
Renderer-backed shell sessions are bounded by default to one per SSH connection
|
|
277
|
+
and 100 across the server. Excess shell requests are rejected without closing the
|
|
278
|
+
SSH connection or reporting an error. Adjust both positive-integer limits when an
|
|
279
|
+
application intentionally needs more concurrency:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
const server = createServer({
|
|
283
|
+
limits: {
|
|
284
|
+
session: {
|
|
285
|
+
perConnection: 2,
|
|
286
|
+
global: 200,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
}).serve(handler)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Capacity remains reserved until the shell transport finishes teardown. These
|
|
293
|
+
limits bound application renderers; they do not replace authentication, network
|
|
294
|
+
access controls, connection-rate limiting, or process resource limits.
|
|
295
|
+
|
|
296
|
+
There is no lifecycle event bus and no pluggable logger — the work splits by
|
|
297
|
+
**verb**, with no overlap:
|
|
298
|
+
|
|
299
|
+
- **react** to a session → middleware + `session.onClose` (deny, enrich, tear down)
|
|
300
|
+
- **observe** the connection lifecycle → the `logging` middleware (connect/disconnect/duration)
|
|
301
|
+
- **report** an error → `onError`, the single error sink
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
let live = 0 // server-wide aggregate is just a counter
|
|
305
|
+
|
|
306
|
+
const server = createServer({
|
|
307
|
+
auth: "open",
|
|
308
|
+
idleTimeout: "10m", // reap a session after no client input ("30s", "500ms", or ms)
|
|
309
|
+
maxTimeout: "1h", // absolute session lifetime, regardless of activity
|
|
310
|
+
startupBanner: true, // set false to silence listen()'s summary
|
|
311
|
+
onError: (err) => console.error(err), // the one error sink; this is the default
|
|
312
|
+
}).serve((session) => {
|
|
313
|
+
live++
|
|
314
|
+
session.onClose(() => {
|
|
315
|
+
live-- // your own per-session bookkeeping (the renderer is torn down for you)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- **the handler + `session.onClose`** are the per-session lifecycle: set up on
|
|
321
|
+
entry, tear down on disconnect. Anything reusable/cross-cutting is a middleware.
|
|
322
|
+
- **`onError(err)`** is the runtime error sink. Contained application and transport errors land here —
|
|
323
|
+
a throwing handler/middleware, a throwing `onResize`/`onClose`, a throwing auth
|
|
324
|
+
predicate, connection- and server-level `ssh2` errors. Defaults to
|
|
325
|
+
`console.error`. It is _reporting_, not reacting — to react to a session, use
|
|
326
|
+
middleware / `onClose`; to observe lifecycle, use the `logging` middleware. (A
|
|
327
|
+
bind failure rejects `listen()` rather than coming here; logging sink failures
|
|
328
|
+
are isolated and ignored so observability cannot affect a session.)
|
|
329
|
+
- **`idleTimeout`** reaps a session after that long with **no client input**
|
|
330
|
+
(re-armed on every keystroke). Per-session: only the idle session is dropped;
|
|
331
|
+
active sessions and the listener are untouched. Durations must be between `1ms`
|
|
332
|
+
and `24h`.
|
|
333
|
+
- **`maxTimeout`** reaps a session after that absolute lifetime, even when the
|
|
334
|
+
client keeps sending input. Durations must be between `1ms` and `24h`.
|
|
335
|
+
- **`listen()`** binds, prints the startup banner (URL, host-key fingerprints,
|
|
336
|
+
auth methods, allowlist fingerprints) to stdout unless `startupBanner: false`,
|
|
337
|
+
and returns `{ host, port, fingerprints }`.
|
|
338
|
+
- **`close()`** stops accepting, destroys live renderers, and closes the listener
|
|
339
|
+
— a graceful, global shutdown.
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
@opentui/ssh ▸ ssh://localhost:2222
|
|
343
|
+
host key SHA256:nThbg6kX…0bGQ (ssh-ed25519, generated ./host_key)
|
|
344
|
+
auth publickey, password
|
|
345
|
+
authorized 2 keys · SHA256:abc… SHA256:def…
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## License
|
|
349
|
+
|
|
350
|
+
MIT
|