terminal-expose 1.0.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.
- package/LICENSE +14 -0
- package/README.md +86 -0
- package/index.js +519 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Durga Prasad
|
|
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.
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Terminal Expose
|
|
2
|
+
|
|
3
|
+
Read-only terminal sharing from your own machine.
|
|
4
|
+
|
|
5
|
+
`terminal-expose` starts a real shell or command on the host where it is run, prints a private viewer URL, and streams the terminal output to anyone who opens that URL.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g terminal-expose
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
Start your default shell:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
terminal-expose
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Start a specific shell:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
terminal-expose bash
|
|
25
|
+
terminal-expose zsh
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run a command:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
terminal-expose bash -lc "npm test"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The CLI prints a URL like:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
Local URL : http://localhost:3000/s/<token>
|
|
38
|
+
LAN URL : http://192.168.1.10:3000/s/<token>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Share the full token URL with the viewer. Viewers are read-only.
|
|
42
|
+
|
|
43
|
+
## Public Internet Access
|
|
44
|
+
|
|
45
|
+
For quick public sharing, start a localtunnel:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
terminal-expose --public bash
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The CLI prints a URL like:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
Public URL: https://random-name.loca.lt/s/<token>
|
|
55
|
+
Tunnel password: <localtunnel-password>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
You can request a custom localtunnel subdomain:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
terminal-expose --public --subdomain my-terminal-demo bash
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The tunnel URL works outside your Wi-Fi as long as the CLI is still running. If `loca.lt` asks the viewer for a tunnel password, share the printed tunnel password too.
|
|
65
|
+
|
|
66
|
+
For a permanent setup, run it on a public server or forward TCP port `3000` to the machine running the CLI:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
EXTERNAL_URL="http://YOUR_PUBLIC_IP:3000" terminal-expose bash
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
Use environment variables:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
PORT=4000 terminal-expose bash
|
|
78
|
+
SESSION_TOKEN="change-this-secret" terminal-expose bash
|
|
79
|
+
PUBLIC_TUNNEL=1 terminal-expose bash
|
|
80
|
+
TUNNEL_SUBDOMAIN="my-terminal-demo" PUBLIC_TUNNEL=1 terminal-expose bash
|
|
81
|
+
TERM_COLS=160 TERM_ROWS=48 terminal-expose bash
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Security
|
|
85
|
+
|
|
86
|
+
Anyone with the full URL can watch the session. Do not type passwords, API keys, or private commands in a shared terminal.
|
package/index.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const express = require("express");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const { Server } = require("socket.io");
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const https = require("https");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const pty = require("node-pty");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const localtunnel = require("localtunnel");
|
|
12
|
+
|
|
13
|
+
const app = express();
|
|
14
|
+
const server = http.createServer(app);
|
|
15
|
+
|
|
16
|
+
const PORT = Number(process.env.PORT || 3000);
|
|
17
|
+
const HOST = process.env.HOST || "0.0.0.0";
|
|
18
|
+
const MAX_BUFFER = Number(process.env.MAX_BUFFER || 100000);
|
|
19
|
+
const ECHO_TO_STDOUT = process.env.ECHO_TO_STDOUT !== "0";
|
|
20
|
+
const SESSION_TOKEN =
|
|
21
|
+
process.env.SESSION_TOKEN || crypto.randomBytes(18).toString("base64url");
|
|
22
|
+
const rawArgs = process.argv.slice(2);
|
|
23
|
+
|
|
24
|
+
function parseArgs(args) {
|
|
25
|
+
const options = {
|
|
26
|
+
publicTunnel: false,
|
|
27
|
+
tunnelSubdomain: process.env.TUNNEL_SUBDOMAIN || null,
|
|
28
|
+
tunnelHost: process.env.TUNNEL_HOST || undefined,
|
|
29
|
+
};
|
|
30
|
+
const commandArgs = [];
|
|
31
|
+
|
|
32
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
33
|
+
const arg = args[index];
|
|
34
|
+
|
|
35
|
+
if (arg === "--public" || arg === "--tunnel") {
|
|
36
|
+
options.publicTunnel = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (arg === "--subdomain") {
|
|
41
|
+
options.tunnelSubdomain = args[index + 1] || null;
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (arg.startsWith("--subdomain=")) {
|
|
47
|
+
options.tunnelSubdomain = arg.slice("--subdomain=".length) || null;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg === "--tunnel-host") {
|
|
52
|
+
options.tunnelHost = args[index + 1] || undefined;
|
|
53
|
+
index += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (arg.startsWith("--tunnel-host=")) {
|
|
58
|
+
options.tunnelHost = arg.slice("--tunnel-host=".length) || undefined;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
commandArgs.push(arg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
process.env.PUBLIC_TUNNEL === "1" ||
|
|
67
|
+
process.env.PUBLIC_TUNNEL === "true"
|
|
68
|
+
) {
|
|
69
|
+
options.publicTunnel = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { options, commandArgs };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { options, commandArgs: command } = parseArgs(rawArgs);
|
|
76
|
+
|
|
77
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
78
|
+
console.log(`Terminal Expose
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
terminal-expose [options] [command] [args...]
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
terminal-expose
|
|
85
|
+
terminal-expose bash
|
|
86
|
+
terminal-expose zsh
|
|
87
|
+
terminal-expose bash -lc "cd ~/project && npm test"
|
|
88
|
+
terminal-expose --public bash
|
|
89
|
+
terminal-expose --public --subdomain my-demo bash
|
|
90
|
+
|
|
91
|
+
Environment:
|
|
92
|
+
PORT=3000
|
|
93
|
+
HOST=0.0.0.0
|
|
94
|
+
SESSION_TOKEN=<custom-token>
|
|
95
|
+
EXTERNAL_URL=http://YOUR_PUBLIC_IP:3000
|
|
96
|
+
PUBLIC_TUNNEL=1
|
|
97
|
+
TUNNEL_SUBDOMAIN=my-demo
|
|
98
|
+
TUNNEL_HOST=https://localtunnel.me
|
|
99
|
+
TERM_COLS=120
|
|
100
|
+
TERM_ROWS=40
|
|
101
|
+
|
|
102
|
+
Options:
|
|
103
|
+
--public, --tunnel Create a public HTTPS tunnel.
|
|
104
|
+
--subdomain <name> Request a localtunnel subdomain.
|
|
105
|
+
--tunnel-host <url> Use a different localtunnel server.
|
|
106
|
+
`);
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
|
|
111
|
+
const packageJson = require("./package.json");
|
|
112
|
+
console.log(packageJson.version);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const shellCommand =
|
|
117
|
+
command.length > 0
|
|
118
|
+
? command[0]
|
|
119
|
+
: process.platform === "win32"
|
|
120
|
+
? "powershell.exe"
|
|
121
|
+
: process.env.SHELL || "bash";
|
|
122
|
+
const shellArgs = command.length > 0 ? command.slice(1) : [];
|
|
123
|
+
|
|
124
|
+
function getLanUrls() {
|
|
125
|
+
const urls = [];
|
|
126
|
+
const interfaces = os.networkInterfaces();
|
|
127
|
+
|
|
128
|
+
Object.values(interfaces).forEach((addresses = []) => {
|
|
129
|
+
addresses
|
|
130
|
+
.filter((address) => address.family === "IPv4" && !address.internal)
|
|
131
|
+
.forEach((address) => {
|
|
132
|
+
urls.push(`http://${address.address}:${PORT}/s/${SESSION_TOKEN}`);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return urls;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getLocaltunnelPassword() {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let settled = false;
|
|
142
|
+
|
|
143
|
+
function finish(value) {
|
|
144
|
+
if (settled) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
settled = true;
|
|
149
|
+
resolve(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const request = https.get("https://loca.lt/mytunnelpassword", (response) => {
|
|
153
|
+
if (response.statusCode !== 200) {
|
|
154
|
+
response.resume();
|
|
155
|
+
finish(null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let body = "";
|
|
160
|
+
response.setEncoding("utf8");
|
|
161
|
+
response.on("data", (chunk) => {
|
|
162
|
+
body += chunk;
|
|
163
|
+
});
|
|
164
|
+
response.on("end", () => {
|
|
165
|
+
finish(body.trim() || null);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
request.setTimeout(3000, () => {
|
|
170
|
+
request.destroy();
|
|
171
|
+
finish(null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
request.on("error", () => {
|
|
175
|
+
finish(null);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function startPublicTunnel() {
|
|
181
|
+
const localHost =
|
|
182
|
+
HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
|
|
183
|
+
const tunnelOptions = {
|
|
184
|
+
port: PORT,
|
|
185
|
+
local_host: localHost,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (options.tunnelSubdomain) {
|
|
189
|
+
tunnelOptions.subdomain = options.tunnelSubdomain;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.tunnelHost) {
|
|
193
|
+
tunnelOptions.host = options.tunnelHost;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log("Public tunnel: starting...");
|
|
197
|
+
|
|
198
|
+
const tunnel = await localtunnel(tunnelOptions);
|
|
199
|
+
const publicUrl = `${tunnel.url.replace(/\/$/, "")}/s/${SESSION_TOKEN}`;
|
|
200
|
+
|
|
201
|
+
console.log(`Public URL: ${publicUrl}`);
|
|
202
|
+
|
|
203
|
+
if (new URL(tunnel.url).hostname.endsWith(".loca.lt")) {
|
|
204
|
+
const password = await getLocaltunnelPassword();
|
|
205
|
+
|
|
206
|
+
if (password) {
|
|
207
|
+
console.log(`Tunnel password: ${password}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
tunnel.on("error", (error) => {
|
|
212
|
+
console.error(`Public tunnel error: ${error.message}`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
tunnel.on("close", () => {
|
|
216
|
+
console.log("Public tunnel closed.");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
["SIGINT", "SIGTERM"].forEach((signal) => {
|
|
220
|
+
process.once(signal, () => {
|
|
221
|
+
tunnel.close();
|
|
222
|
+
process.kill(process.pid, signal);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return tunnel;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const VIEWER_HTML = String.raw`<!doctype html>
|
|
230
|
+
<html>
|
|
231
|
+
<head>
|
|
232
|
+
<meta charset="utf-8" />
|
|
233
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
234
|
+
<title>Terminal Viewer</title>
|
|
235
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
|
|
236
|
+
<style>
|
|
237
|
+
html,
|
|
238
|
+
body {
|
|
239
|
+
margin: 0;
|
|
240
|
+
padding: 0;
|
|
241
|
+
background: #050505;
|
|
242
|
+
color: #f2f2f2;
|
|
243
|
+
width: 100%;
|
|
244
|
+
height: 100%;
|
|
245
|
+
overflow: hidden;
|
|
246
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.topbar {
|
|
250
|
+
align-items: center;
|
|
251
|
+
background: #111;
|
|
252
|
+
border-bottom: 1px solid #2a2a2a;
|
|
253
|
+
box-sizing: border-box;
|
|
254
|
+
display: flex;
|
|
255
|
+
gap: 12px;
|
|
256
|
+
height: 42px;
|
|
257
|
+
justify-content: space-between;
|
|
258
|
+
padding: 0 14px;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.title {
|
|
262
|
+
font-size: 13px;
|
|
263
|
+
font-weight: 650;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.status {
|
|
267
|
+
align-items: center;
|
|
268
|
+
color: #b8b8b8;
|
|
269
|
+
display: flex;
|
|
270
|
+
font-size: 12px;
|
|
271
|
+
gap: 8px;
|
|
272
|
+
min-width: 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.dot {
|
|
276
|
+
background: #9ca3af;
|
|
277
|
+
border-radius: 999px;
|
|
278
|
+
flex: 0 0 auto;
|
|
279
|
+
height: 8px;
|
|
280
|
+
width: 8px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.dot.connected {
|
|
284
|
+
background: #22c55e;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.dot.disconnected {
|
|
288
|
+
background: #ef4444;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#terminal {
|
|
292
|
+
width: 100vw;
|
|
293
|
+
height: calc(100vh - 42px);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.xterm {
|
|
297
|
+
height: 100%;
|
|
298
|
+
}
|
|
299
|
+
</style>
|
|
300
|
+
</head>
|
|
301
|
+
<body>
|
|
302
|
+
<header class="topbar">
|
|
303
|
+
<div class="title">Terminal Expose</div>
|
|
304
|
+
<div class="status">
|
|
305
|
+
<span id="dot" class="dot"></span>
|
|
306
|
+
<span id="status">Connecting</span>
|
|
307
|
+
</div>
|
|
308
|
+
</header>
|
|
309
|
+
<div id="terminal"></div>
|
|
310
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
311
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
|
|
312
|
+
<script>
|
|
313
|
+
const token = window.location.pathname.split('/').filter(Boolean).pop();
|
|
314
|
+
const statusText = document.getElementById('status');
|
|
315
|
+
const dot = document.getElementById('dot');
|
|
316
|
+
const term = new Terminal({
|
|
317
|
+
disableStdin: true,
|
|
318
|
+
cursorBlink: false,
|
|
319
|
+
convertEol: true,
|
|
320
|
+
fontFamily: '"JetBrains Mono", "SFMono-Regular", Consolas, monospace',
|
|
321
|
+
fontSize: 14,
|
|
322
|
+
scrollback: 10000,
|
|
323
|
+
theme: {
|
|
324
|
+
background: '#050505',
|
|
325
|
+
foreground: '#f4f4f5',
|
|
326
|
+
cursor: '#f4f4f5',
|
|
327
|
+
black: '#18181b',
|
|
328
|
+
red: '#ef4444',
|
|
329
|
+
green: '#22c55e',
|
|
330
|
+
yellow: '#eab308',
|
|
331
|
+
blue: '#3b82f6',
|
|
332
|
+
magenta: '#a855f7',
|
|
333
|
+
cyan: '#06b6d4',
|
|
334
|
+
white: '#e4e4e7'
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
term.open(document.getElementById('terminal'));
|
|
339
|
+
|
|
340
|
+
function setStatus(text, className) {
|
|
341
|
+
statusText.textContent = text;
|
|
342
|
+
dot.className = 'dot ' + (className || '');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const socket = io({
|
|
346
|
+
auth: { token },
|
|
347
|
+
reconnectionAttempts: 10,
|
|
348
|
+
timeout: 5000
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
socket.on('connect', () => {
|
|
352
|
+
setStatus('Connected', 'connected');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
socket.on('terminal-output', (data) => {
|
|
356
|
+
term.write(data);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
socket.on('terminal-exit', ({ exitCode, signal }) => {
|
|
360
|
+
const suffix = signal ? 'signal ' + signal : 'exit code ' + exitCode;
|
|
361
|
+
setStatus('Command ended: ' + suffix, 'disconnected');
|
|
362
|
+
term.writeln('\r\n[Command ended: ' + suffix + ']');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
socket.on('connect_error', (error) => {
|
|
366
|
+
setStatus(error.message || 'Connection error', 'disconnected');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
socket.on('disconnect', () => {
|
|
370
|
+
setStatus('Disconnected', 'disconnected');
|
|
371
|
+
});
|
|
372
|
+
</script>
|
|
373
|
+
</body>
|
|
374
|
+
</html>`;
|
|
375
|
+
|
|
376
|
+
const io = new Server(server, {
|
|
377
|
+
cors: {
|
|
378
|
+
origin: false,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
io.use((socket, next) => {
|
|
383
|
+
const token = socket.handshake.auth?.token;
|
|
384
|
+
|
|
385
|
+
if (token !== SESSION_TOKEN) {
|
|
386
|
+
return next(new Error("unauthorized"));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return next();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const shell = pty.spawn(shellCommand, shellArgs, {
|
|
393
|
+
name: "xterm-256color",
|
|
394
|
+
cols: Number(process.env.TERM_COLS || 120),
|
|
395
|
+
rows: Number(process.env.TERM_ROWS || 40),
|
|
396
|
+
cwd: process.env.WORKDIR || process.cwd(),
|
|
397
|
+
env: {
|
|
398
|
+
...process.env,
|
|
399
|
+
TERM: "xterm-256color",
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
let terminalBuffer = "";
|
|
404
|
+
let terminalExit = null;
|
|
405
|
+
|
|
406
|
+
if (process.stdin.isTTY) {
|
|
407
|
+
process.stdin.setRawMode(true);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
process.stdin.resume();
|
|
411
|
+
process.stdin.on("data", (data) => {
|
|
412
|
+
shell.write(data);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
shell.onData((data) => {
|
|
416
|
+
if (ECHO_TO_STDOUT) {
|
|
417
|
+
process.stdout.write(data);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
terminalBuffer += data;
|
|
421
|
+
|
|
422
|
+
if (terminalBuffer.length > MAX_BUFFER) {
|
|
423
|
+
terminalBuffer = terminalBuffer.slice(-MAX_BUFFER);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
io.emit("terminal-output", data);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
shell.onExit(({ exitCode, signal }) => {
|
|
430
|
+
terminalExit = { exitCode, signal };
|
|
431
|
+
io.emit("terminal-exit", terminalExit);
|
|
432
|
+
console.log(`Terminal command exited: code=${exitCode} signal=${signal}`);
|
|
433
|
+
|
|
434
|
+
if (process.stdin.isTTY) {
|
|
435
|
+
process.stdin.setRawMode(false);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
setTimeout(() => {
|
|
439
|
+
process.exit(exitCode ?? 0);
|
|
440
|
+
}, 250);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
app.use("/assets", express.static(path.join(__dirname, "public")));
|
|
444
|
+
|
|
445
|
+
app.get("/", (_, res) => {
|
|
446
|
+
res.type("html").send(`<!doctype html>
|
|
447
|
+
<html>
|
|
448
|
+
<head>
|
|
449
|
+
<meta charset="utf-8" />
|
|
450
|
+
<title>Terminal Expose</title>
|
|
451
|
+
</head>
|
|
452
|
+
<body style="font-family: system-ui, sans-serif; line-height: 1.5; padding: 32px;">
|
|
453
|
+
<h1>Terminal Expose</h1>
|
|
454
|
+
<p>This server is running. Use the private session URL printed in the container logs.</p>
|
|
455
|
+
</body>
|
|
456
|
+
</html>`);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
app.get("/health", (_, res) => {
|
|
460
|
+
res.json({
|
|
461
|
+
ok: true,
|
|
462
|
+
viewers: io.engine.clientsCount,
|
|
463
|
+
exited: terminalExit,
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
app.get("/s/:token", (req, res) => {
|
|
468
|
+
if (req.params.token !== SESSION_TOKEN) {
|
|
469
|
+
return res.status(403).send("Invalid session token");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
res.type("html").send(VIEWER_HTML);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
io.on("connection", (socket) => {
|
|
476
|
+
console.log(`Viewer connected: ${socket.id}`);
|
|
477
|
+
|
|
478
|
+
if (terminalBuffer) {
|
|
479
|
+
socket.emit("terminal-output", terminalBuffer);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (terminalExit) {
|
|
483
|
+
socket.emit("terminal-exit", terminalExit);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
socket.on("disconnect", () => {
|
|
487
|
+
console.log(`Viewer disconnected: ${socket.id}`);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
server.listen(PORT, HOST, () => {
|
|
492
|
+
const localUrl = `http://localhost:${PORT}/s/${SESSION_TOKEN}`;
|
|
493
|
+
const externalUrl = process.env.EXTERNAL_URL
|
|
494
|
+
? `${process.env.EXTERNAL_URL.replace(/\/$/, "")}/s/${SESSION_TOKEN}`
|
|
495
|
+
: null;
|
|
496
|
+
|
|
497
|
+
console.log("\n==============================");
|
|
498
|
+
console.log("Terminal Expose Started");
|
|
499
|
+
console.log("==============================");
|
|
500
|
+
console.log(`Command : ${[shellCommand, ...shellArgs].join(" ")}`);
|
|
501
|
+
console.log(`Local URL : ${localUrl}`);
|
|
502
|
+
|
|
503
|
+
getLanUrls().forEach((url) => console.log(`LAN URL : ${url}`));
|
|
504
|
+
|
|
505
|
+
if (externalUrl) {
|
|
506
|
+
console.log(`Public URL: ${externalUrl}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (options.publicTunnel) {
|
|
510
|
+
startPublicTunnel().catch((error) => {
|
|
511
|
+
console.error(`Public tunnel failed: ${error.message}`);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
console.log("");
|
|
516
|
+
console.log("Share only the session URL with trusted viewers.");
|
|
517
|
+
console.log("Use --public for internet access without router forwarding.");
|
|
518
|
+
console.log("==============================\n");
|
|
519
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "terminal-expose",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Read-only terminal session sharing over HTTP and WebSocket.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"terminal-expose": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"public"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node index.js",
|
|
15
|
+
"start:shell": "node index.js bash",
|
|
16
|
+
"start:public": "node index.js --public bash",
|
|
17
|
+
"test": "node --check index.js",
|
|
18
|
+
"pack:dry": "npm pack --dry-run"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"terminal",
|
|
22
|
+
"shell",
|
|
23
|
+
"sharing",
|
|
24
|
+
"xterm",
|
|
25
|
+
"pty",
|
|
26
|
+
"websocket"
|
|
27
|
+
],
|
|
28
|
+
"author": "Durga Prasad Thota",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"express": "^5.2.1",
|
|
35
|
+
"localtunnel": "^2.0.2",
|
|
36
|
+
"node-pty": "^1.1.0",
|
|
37
|
+
"socket.io": "^4.8.3"
|
|
38
|
+
},
|
|
39
|
+
"overrides": {
|
|
40
|
+
"localtunnel": {
|
|
41
|
+
"axios": "^1.16.1"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|