terminal-expose 1.0.0 → 1.2.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Terminal Expose
2
2
 
3
- Read-only terminal sharing from your own machine.
3
+ Interactive terminal sharing from your own machine.
4
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.
5
+ `terminal-expose` starts a real shell or command on the host where it is run, prints a private URL, and allows collaborative, interactive access to anyone who opens that URL.
6
6
 
7
7
  ## Install
8
8
 
@@ -34,15 +34,15 @@ terminal-expose bash -lc "npm test"
34
34
  The CLI prints a URL like:
35
35
 
36
36
  ```text
37
- Local URL : http://localhost:3000/s/<token>
38
- LAN URL : http://192.168.1.10:3000/s/<token>
37
+ Local URL : http://localhost:5555/s/<token>
38
+ LAN URL : http://192.168.1.10:5555/s/<token>
39
39
  ```
40
40
 
41
- Share the full token URL with the viewer. Viewers are read-only.
41
+ Share the full token URL with your collaborators. They will have full read and write access to the terminal.
42
42
 
43
43
  ## Public Internet Access
44
44
 
45
- For quick public sharing, start a localtunnel:
45
+ For quick public sharing, start a secure edge tunnel (powered by Cloudflare Quick Tunnels):
46
46
 
47
47
  ```sh
48
48
  terminal-expose --public bash
@@ -51,22 +51,17 @@ terminal-expose --public bash
51
51
  The CLI prints a URL like:
52
52
 
53
53
  ```text
54
- Public URL: https://random-name.loca.lt/s/<token>
55
- Tunnel password: <localtunnel-password>
54
+ Public URL: https://random-name.trycloudflare.com/s/<token>
56
55
  ```
57
56
 
58
- You can request a custom localtunnel subdomain:
57
+ _(Note: Custom subdomains are not currently supported by free Cloudflare Quick Tunnels.)_
59
58
 
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.
59
+ The tunnel URL works outside your Wi-Fi as long as the CLI is still running.
65
60
 
66
- For a permanent setup, run it on a public server or forward TCP port `3000` to the machine running the CLI:
61
+ For a permanent setup, run it on a public server or forward TCP port 5555 to the machine running the CLI:
67
62
 
68
63
  ```sh
69
- EXTERNAL_URL="http://YOUR_PUBLIC_IP:3000" terminal-expose bash
64
+ EXTERNAL_URL="http://YOUR_PUBLIC_IP:5555" terminal-expose bash
70
65
  ```
71
66
 
72
67
  ## Options
@@ -77,10 +72,9 @@ Use environment variables:
77
72
  PORT=4000 terminal-expose bash
78
73
  SESSION_TOKEN="change-this-secret" terminal-expose bash
79
74
  PUBLIC_TUNNEL=1 terminal-expose bash
80
- TUNNEL_SUBDOMAIN="my-terminal-demo" PUBLIC_TUNNEL=1 terminal-expose bash
81
75
  TERM_COLS=160 TERM_ROWS=48 terminal-expose bash
82
76
  ```
83
77
 
84
78
  ## Security
85
79
 
86
- Anyone with the full URL can watch the session. Do not type passwords, API keys, or private commands in a shared terminal.
80
+ Anyone with the full URL can view and interact with the session. Do not type passwords, API keys, or private commands in a shared terminal.
package/index.js CHANGED
@@ -1,81 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const express = require("express");
4
3
  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");
4
+ const { parseArgs } = require("./src/utils/args");
5
+ const { getDefaultShell, getLanUrls } = require("./src/utils/system");
6
+ const { TerminalSession } = require("./src/core/terminal");
7
+ const { setupSocket } = require("./src/core/socket");
8
+ const { startPublicTunnel } = require("./src/core/tunnel");
9
+ const { createApp } = require("./src/server/app");
12
10
 
13
- const app = express();
14
- const server = http.createServer(app);
11
+ const { PORT, HOST, SESSION_TOKEN, EXTERNAL_URL } = require("./src/config/env");
15
12
 
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");
13
+ // arguments
22
14
  const rawArgs = process.argv.slice(2);
23
15
 
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
16
  if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
78
17
  console.log(`Terminal Expose
18
+ Interactive terminal session sharing over HTTP and WebSocket.
79
19
 
80
20
  Usage:
81
21
  terminal-expose [options] [command] [args...]
@@ -86,23 +26,18 @@ Examples:
86
26
  terminal-expose zsh
87
27
  terminal-expose bash -lc "cd ~/project && npm test"
88
28
  terminal-expose --public bash
89
- terminal-expose --public --subdomain my-demo bash
90
29
 
91
30
  Environment:
92
- PORT=3000
31
+ PORT=5555
93
32
  HOST=0.0.0.0
94
33
  SESSION_TOKEN=<custom-token>
95
- EXTERNAL_URL=http://YOUR_PUBLIC_IP:3000
34
+ EXTERNAL_URL=http://YOUR_PUBLIC_IP:5555
96
35
  PUBLIC_TUNNEL=1
97
- TUNNEL_SUBDOMAIN=my-demo
98
- TUNNEL_HOST=https://localtunnel.me
99
36
  TERM_COLS=120
100
37
  TERM_ROWS=40
101
38
 
102
39
  Options:
103
40
  --public, --tunnel Create a public HTTPS tunnel.
104
- --subdomain <name> Request a localtunnel subdomain.
105
- --tunnel-host <url> Use a different localtunnel server.
106
41
  `);
107
42
  process.exit(0);
108
43
  }
@@ -113,385 +48,41 @@ if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
113
48
  process.exit(0);
114
49
  }
115
50
 
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}`);
51
+ const { options, commandArgs: command } = parseArgs(rawArgs);
433
52
 
434
- if (process.stdin.isTTY) {
435
- process.stdin.setRawMode(false);
436
- }
53
+ const shellCommand = command.length > 0 ? command[0] : getDefaultShell();
54
+ const shellArgs = command.length > 0 ? command.slice(1) : [];
437
55
 
438
- setTimeout(() => {
439
- process.exit(exitCode ?? 0);
440
- }, 250);
441
- });
56
+ // Initialize Terminal
57
+ const terminalSession = new TerminalSession(shellCommand, shellArgs);
442
58
 
443
- app.use("/assets", express.static(path.join(__dirname, "public")));
59
+ // Initialize Express App
60
+ let ioInstance = null;
61
+ const app = createApp(() => ioInstance, terminalSession);
62
+ const server = http.createServer(app);
444
63
 
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
- });
64
+ // Initialize Socket.io
65
+ ioInstance = setupSocket(server, terminalSession);
458
66
 
459
- app.get("/health", (_, res) => {
460
- res.json({
461
- ok: true,
462
- viewers: io.engine.clientsCount,
463
- exited: terminalExit,
67
+ // Close server and process gracefully on terminal exit
68
+ terminalSession.on("exit", () => {
69
+ console.log("Shutting down server port...");
70
+ server.close(() => {
71
+ // Ensure process is fully killed after server cleanly exits
72
+ process.exit(terminalSession.exit?.exitCode ?? 0);
464
73
  });
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
74
 
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
- });
75
+ // Force exit if server.close() hangs (e.g., active keep-alive connections)
76
+ setTimeout(
77
+ () => process.exit(terminalSession.exit?.exitCode ?? 0),
78
+ 1000,
79
+ ).unref();
489
80
  });
490
81
 
491
82
  server.listen(PORT, HOST, () => {
492
83
  const localUrl = `http://localhost:${PORT}/s/${SESSION_TOKEN}`;
493
- const externalUrl = process.env.EXTERNAL_URL
494
- ? `${process.env.EXTERNAL_URL.replace(/\/$/, "")}/s/${SESSION_TOKEN}`
84
+ const externalUrl = EXTERNAL_URL
85
+ ? `${EXTERNAL_URL.replace(/\/$/, "")}/s/${SESSION_TOKEN}`
495
86
  : null;
496
87
 
497
88
  console.log("\n==============================");
@@ -500,20 +91,22 @@ server.listen(PORT, HOST, () => {
500
91
  console.log(`Command : ${[shellCommand, ...shellArgs].join(" ")}`);
501
92
  console.log(`Local URL : ${localUrl}`);
502
93
 
503
- getLanUrls().forEach((url) => console.log(`LAN URL : ${url}`));
94
+ getLanUrls({ port: PORT, session_token: SESSION_TOKEN }).forEach((url) =>
95
+ console.log(`LAN URL : ${url}`),
96
+ );
504
97
 
505
98
  if (externalUrl) {
506
99
  console.log(`Public URL: ${externalUrl}`);
507
100
  }
508
101
 
509
102
  if (options.publicTunnel) {
510
- startPublicTunnel().catch((error) => {
103
+ startPublicTunnel(options, { HOST, PORT, SESSION_TOKEN }).catch((error) => {
511
104
  console.error(`Public tunnel failed: ${error.message}`);
512
105
  });
513
106
  }
514
107
 
515
108
  console.log("");
516
- console.log("Share only the session URL with trusted viewers.");
109
+ console.log("Share only the session URL with trusted collaborators.");
517
110
  console.log("Use --public for internet access without router forwarding.");
518
111
  console.log("==============================\n");
519
112
  });
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "terminal-expose",
3
- "version": "1.0.0",
4
- "description": "Read-only terminal session sharing over HTTP and WebSocket.",
3
+ "version": "1.2.0",
4
+ "description": "Interactive terminal session sharing over HTTP and WebSocket.",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "terminal-expose": "index.js"
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
+ "src",
11
12
  "public"
12
13
  ],
13
14
  "scripts": {
@@ -32,13 +33,8 @@
32
33
  },
33
34
  "dependencies": {
34
35
  "express": "^5.2.1",
35
- "localtunnel": "^2.0.2",
36
36
  "node-pty": "^1.1.0",
37
- "socket.io": "^4.8.3"
38
- },
39
- "overrides": {
40
- "localtunnel": {
41
- "axios": "^1.16.1"
42
- }
37
+ "socket.io": "^4.8.3",
38
+ "untun": "^0.1.3"
43
39
  }
44
40
  }
@@ -0,0 +1,24 @@
1
+ const crypto = require("crypto");
2
+
3
+ const PORT = Number(process.env.PORT || 5555);
4
+ const HOST = process.env.HOST || "0.0.0.0";
5
+ const MAX_BUFFER = Number(process.env.MAX_BUFFER || 100000);
6
+ const ECHO_TO_STDOUT = process.env.ECHO_TO_STDOUT !== "0";
7
+ const SESSION_TOKEN =
8
+ process.env.SESSION_TOKEN || crypto.randomBytes(18).toString("base64url");
9
+ const TERM_COLS = Number(process.env.TERM_COLS || 120);
10
+ const TERM_ROWS = Number(process.env.TERM_ROWS || 40);
11
+ const WORKDIR = process.env.WORKDIR || process.cwd();
12
+ const EXTERNAL_URL = process.env.EXTERNAL_URL || null;
13
+
14
+ module.exports = {
15
+ PORT,
16
+ HOST,
17
+ MAX_BUFFER,
18
+ ECHO_TO_STDOUT,
19
+ SESSION_TOKEN,
20
+ TERM_COLS,
21
+ TERM_ROWS,
22
+ WORKDIR,
23
+ EXTERNAL_URL,
24
+ };