terminal-expose 1.1.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
@@ -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
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 user 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,7 +72,6 @@ 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
 
package/index.js CHANGED
@@ -26,23 +26,18 @@ Examples:
26
26
  terminal-expose zsh
27
27
  terminal-expose bash -lc "cd ~/project && npm test"
28
28
  terminal-expose --public bash
29
- terminal-expose --public --subdomain my-demo bash
30
29
 
31
30
  Environment:
32
- PORT=3000
31
+ PORT=5555
33
32
  HOST=0.0.0.0
34
33
  SESSION_TOKEN=<custom-token>
35
- EXTERNAL_URL=http://YOUR_PUBLIC_IP:3000
34
+ EXTERNAL_URL=http://YOUR_PUBLIC_IP:5555
36
35
  PUBLIC_TUNNEL=1
37
- TUNNEL_SUBDOMAIN=my-demo
38
- TUNNEL_HOST=https://localtunnel.me
39
36
  TERM_COLS=120
40
37
  TERM_ROWS=40
41
38
 
42
39
  Options:
43
40
  --public, --tunnel Create a public HTTPS tunnel.
44
- --subdomain <name> Request a localtunnel subdomain.
45
- --tunnel-host <url> Use a different localtunnel server.
46
41
  `);
47
42
  process.exit(0);
48
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-expose",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Interactive terminal session sharing over HTTP and WebSocket.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -33,13 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "express": "^5.2.1",
36
- "localtunnel": "^2.0.2",
37
36
  "node-pty": "^1.1.0",
38
- "socket.io": "^4.8.3"
39
- },
40
- "overrides": {
41
- "localtunnel": {
42
- "axios": "^1.16.1"
43
- }
37
+ "socket.io": "^4.8.3",
38
+ "untun": "^0.1.3"
44
39
  }
45
40
  }
package/src/config/env.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const crypto = require("crypto");
2
2
 
3
- const PORT = Number(process.env.PORT || 1111);
3
+ const PORT = Number(process.env.PORT || 5555);
4
4
  const HOST = process.env.HOST || "0.0.0.0";
5
5
  const MAX_BUFFER = Number(process.env.MAX_BUFFER || 100000);
6
6
  const ECHO_TO_STDOUT = process.env.ECHO_TO_STDOUT !== "0";
@@ -15,8 +15,6 @@ class TerminalSession {
15
15
 
16
16
  this.shell = pty.spawn(shellCommand, shellArgs, {
17
17
  name: "xterm-256color",
18
- // cols: TERM_COLS,
19
- // rows: TERM_ROWS,
20
18
  cwd: WORKDIR,
21
19
  env: {
22
20
  ...process.env,
@@ -24,33 +22,40 @@ class TerminalSession {
24
22
  },
25
23
  });
26
24
 
25
+ // verify if terminal was available or not bro
27
26
  if (process.stdin.isTTY) {
27
+ // listening to every stroke of host terminal input
28
28
  process.stdin.setRawMode(true);
29
29
  }
30
30
  process.stdin.resume();
31
+
32
+ // write to shell after getting the input from host terminal
31
33
  process.stdin.on("data", (data) => {
32
34
  this.shell.write(data);
33
35
  });
34
36
 
37
+ // after getting the data from shell writing it to host terminal
35
38
  this.shell.onData((data) => {
36
39
  if (ECHO_TO_STDOUT) {
40
+ // this write the user input to the host terminal
37
41
  process.stdout.write(data);
38
42
  }
39
43
 
40
44
  this.buffer += data;
41
-
42
45
  if (this.buffer.length > MAX_BUFFER) {
43
46
  this.buffer = this.buffer.slice(-MAX_BUFFER);
44
47
  }
45
48
 
49
+ // ! need to verify it was need or not
46
50
  this.emit("data", data);
47
51
  });
48
52
 
53
+ // if the terminal was exited in host machine exit the process and inform the listeing clients
49
54
  this.shell.onExit(({ exitCode, signal }) => {
50
55
  this.exit = { exitCode, signal };
51
56
  this.emit("exit", this.exit);
52
57
 
53
- console.log(`Terminal command exited: code=${exitCode} signal=${signal}`);
58
+ console.log(`Terminal-Expose exited: code=${exitCode} signal=${signal}`);
54
59
 
55
60
  if (process.stdin.isTTY) {
56
61
  process.stdin.setRawMode(false);
@@ -1,96 +1,40 @@
1
- const https = require("https");
2
- const localtunnel = require("localtunnel");
3
-
4
- function getLocaltunnelPassword() {
5
- return new Promise((resolve) => {
6
- let settled = false;
7
-
8
- function finish(value) {
9
- if (settled) {
10
- return;
11
- }
12
-
13
- settled = true;
14
- resolve(value);
15
- }
16
-
17
- const request = https.get(
18
- "https://loca.lt/mytunnelpassword",
19
- (response) => {
20
- if (response.statusCode !== 200) {
21
- response.resume();
22
- finish(null);
23
- return;
24
- }
25
-
26
- let body = "";
27
- response.setEncoding("utf8");
28
- response.on("data", (chunk) => {
29
- body += chunk;
30
- });
31
- response.on("end", () => {
32
- finish(body.trim() || null);
33
- });
34
- },
35
- );
36
-
37
- request.setTimeout(3000, () => {
38
- request.destroy();
39
- finish(null);
40
- });
41
-
42
- request.on("error", () => {
43
- finish(null);
44
- });
45
- });
46
- }
1
+ const { startTunnel } = require("untun");
47
2
 
48
3
  async function startPublicTunnel(options, { HOST, PORT, SESSION_TOKEN }) {
49
4
  const localHost = HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
50
- const tunnelOptions = {
51
- port: PORT,
52
- local_host: localHost,
53
- };
54
5
 
55
- if (options.tunnelSubdomain) {
56
- tunnelOptions.subdomain = options.tunnelSubdomain;
57
- }
58
-
59
- if (options.tunnelHost) {
60
- tunnelOptions.host = options.tunnelHost;
6
+ if (options.tunnelSubdomain || options.tunnelHost) {
7
+ console.warn(
8
+ "Warning: Custom subdomain and tunnel host are not supported with Cloudflare Quick Tunnels.",
9
+ );
61
10
  }
62
11
 
63
- console.log("Public tunnel: starting...");
64
-
65
- const tunnel = await localtunnel(tunnelOptions);
66
- const publicUrl = `${tunnel.url.replace(/\/$/, "")}/s/${SESSION_TOKEN}`;
67
-
68
- console.log(`Public URL: ${publicUrl}`);
69
-
70
- if (new URL(tunnel.url).hostname.endsWith(".loca.lt")) {
71
- const password = await getLocaltunnelPassword();
12
+ console.log("Public tunnel: starting cloudflared...");
72
13
 
73
- if (password) {
74
- console.log(`Tunnel password: ${password}`);
75
- }
76
- }
14
+ try {
15
+ const tunnel = await startTunnel({
16
+ port: PORT,
17
+ hostname: localHost,
18
+ acceptCloudflareNotice: true,
19
+ });
77
20
 
78
- tunnel.on("error", (error) => {
79
- console.error(`Public tunnel error: ${error.message}`);
80
- });
21
+ const url = await tunnel.getURL();
22
+ const publicUrl = `${url.replace(/\/$/, "")}/s/${SESSION_TOKEN}`;
81
23
 
82
- tunnel.on("close", () => {
83
- console.log("Public tunnel closed.");
84
- });
24
+ console.log(`Public URL: ${publicUrl}`);
85
25
 
86
- ["SIGINT", "SIGTERM"].forEach((signal) => {
87
- process.once(signal, () => {
88
- tunnel.close();
89
- process.kill(process.pid, signal);
26
+ ["SIGINT", "SIGTERM"].forEach((signal) => {
27
+ process.once(signal, async () => {
28
+ await tunnel.close();
29
+ process.kill(process.pid, signal);
30
+ });
90
31
  });
91
- });
92
32
 
93
- return tunnel;
33
+ return tunnel;
34
+ } catch (error) {
35
+ console.error(`Public tunnel error: ${error.message}`);
36
+ throw error;
37
+ }
94
38
  }
95
39
 
96
40
  module.exports = { startPublicTunnel };
package/src/server/app.js CHANGED
@@ -1,7 +1,6 @@
1
1
  const express = require("express");
2
2
  const path = require("path");
3
3
  const { SESSION_TOKEN } = require("../config/env");
4
- const { VIEWER_HTML } = require("../templates/viewer");
5
4
 
6
5
  function createApp(ioGetter, terminalSession) {
7
6
  const app = express();
@@ -37,7 +36,7 @@ function createApp(ioGetter, terminalSession) {
37
36
  return res.status(403).send("Invalid session token");
38
37
  }
39
38
 
40
- res.type("html").send(VIEWER_HTML);
39
+ res.sendFile(path.join(__dirname, "../templates/viewer.html"));
41
40
  });
42
41
 
43
42
  return app;
@@ -0,0 +1,482 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
7
+ <title>Terminal Session</title>
8
+
9
+ <link
10
+ rel="stylesheet"
11
+ href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css"
12
+ />
13
+
14
+ <style>
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html,
20
+ body {
21
+ width: 100%;
22
+ height: 100%;
23
+ margin: 0;
24
+
25
+ overflow: hidden;
26
+
27
+ background: #09090b;
28
+ color: #ffffff;
29
+
30
+ font-family:
31
+ Inter,
32
+ system-ui,
33
+ sans-serif;
34
+ }
35
+
36
+
37
+ /* ======================
38
+ Layout
39
+ ====================== */
40
+
41
+ .app {
42
+ height: 100vh;
43
+
44
+ display: flex;
45
+ flex-direction: column;
46
+ }
47
+
48
+
49
+ /* ======================
50
+ Header
51
+ ====================== */
52
+
53
+ .header {
54
+ height: 48px;
55
+
56
+ padding: 0 16px;
57
+
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: space-between;
61
+
62
+ background:
63
+ linear-gradient(
64
+ 180deg,
65
+ #18181b,
66
+ #09090b
67
+ );
68
+
69
+ border-bottom: 1px solid #27272a;
70
+
71
+ user-select: none;
72
+ }
73
+
74
+
75
+ .title {
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+
79
+ color: #e4e4e7;
80
+ }
81
+
82
+
83
+ .actions {
84
+ display: flex;
85
+ align-items: center;
86
+
87
+ gap: 14px;
88
+
89
+ font-size: 13px;
90
+ }
91
+
92
+
93
+
94
+ /* ======================
95
+ Status
96
+ ====================== */
97
+
98
+
99
+ .status {
100
+ display: flex;
101
+ align-items: center;
102
+
103
+ gap: 6px;
104
+ }
105
+
106
+
107
+ .status-dot {
108
+ width: 9px;
109
+ height: 9px;
110
+
111
+ border-radius: 999px;
112
+
113
+ background: currentColor;
114
+
115
+ box-shadow:
116
+ 0 0 12px currentColor;
117
+ }
118
+
119
+
120
+ .connected {
121
+ color: #22c55e;
122
+ }
123
+
124
+
125
+ .connecting {
126
+ color: #f97316;
127
+ }
128
+
129
+
130
+ .disconnected {
131
+ color: #ef4444;
132
+ }
133
+
134
+
135
+
136
+ /* ======================
137
+ Buttons
138
+ ====================== */
139
+
140
+
141
+ button {
142
+ padding: 5px 10px;
143
+
144
+ cursor: pointer;
145
+
146
+ background: #18181b;
147
+ color: #e4e4e7;
148
+
149
+ border:
150
+ 1px solid #3f3f46;
151
+
152
+ border-radius: 6px;
153
+ }
154
+
155
+
156
+ button:hover {
157
+ background: #27272a;
158
+ }
159
+
160
+
161
+
162
+
163
+ /* ======================
164
+ Terminal
165
+ ====================== */
166
+
167
+
168
+ #terminal {
169
+ flex: 1;
170
+
171
+ padding: 12px;
172
+
173
+ overflow: hidden;
174
+ }
175
+
176
+
177
+ .xterm {
178
+ height: 100%;
179
+ }
180
+
181
+
182
+
183
+ /* ======================
184
+ Scrollbar
185
+ ====================== */
186
+
187
+
188
+ .xterm-viewport::-webkit-scrollbar {
189
+ width: 8px;
190
+ }
191
+
192
+
193
+ .xterm-viewport::-webkit-scrollbar-track {
194
+ background: transparent;
195
+ }
196
+
197
+
198
+ .xterm-viewport::-webkit-scrollbar-thumb {
199
+ background: #3f3f46;
200
+
201
+ border-radius: 20px;
202
+ }
203
+
204
+
205
+ .xterm-viewport::-webkit-scrollbar-thumb:hover {
206
+ background: #71717a;
207
+ }
208
+
209
+
210
+
211
+ @media(max-width: 600px) {
212
+
213
+ .title {
214
+ font-size: 12px;
215
+ }
216
+
217
+
218
+ .actions {
219
+ gap: 8px;
220
+ }
221
+
222
+ }
223
+ </style>
224
+ </head>
225
+
226
+ <body>
227
+ <div class="app">
228
+ <header class="header">
229
+ <div class="title">⚡ Remote Terminal</div>
230
+
231
+ <div class="actions">
232
+ <span id="timer"> 00:00 </span>
233
+
234
+ <div id="status" class="status connecting">
235
+ <span class="status-dot"></span>
236
+
237
+ <span id="statusText"> Connecting </span>
238
+ </div>
239
+
240
+ <button id="fullscreen">⛶</button>
241
+ </div>
242
+ </header>
243
+
244
+ <main id="terminal"></main>
245
+ </div>
246
+
247
+ <script src="/socket.io/socket.io.js"></script>
248
+ <script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
249
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
250
+
251
+ <script>
252
+
253
+
254
+ const token =
255
+ location.pathname
256
+ .split("/")
257
+ .filter(Boolean)
258
+ .pop();
259
+
260
+
261
+
262
+ const term =
263
+ new Terminal({
264
+ cursorBlink: true,
265
+ convertEol: true,
266
+ fontFamily:
267
+ `"JetBrains Mono", monospace`,
268
+ fontSize: 14,
269
+ lineHeight: 1.3,
270
+ scrollback: 20000,
271
+ theme: {
272
+ background: "#09090b",
273
+ foreground: "#fafafa",
274
+ cursor: "#22c55e",
275
+ selection: "#334155"
276
+ }
277
+ });
278
+
279
+
280
+
281
+ const fitAddon =
282
+ new FitAddon.FitAddon();
283
+
284
+
285
+
286
+ term.loadAddon(
287
+ fitAddon
288
+ );
289
+
290
+
291
+ term.open(
292
+ document.getElementById(
293
+ "terminal"
294
+ )
295
+ );
296
+
297
+
298
+ fitAddon.fit();
299
+
300
+
301
+
302
+ const socket =
303
+ io({
304
+ auth:{
305
+ token
306
+ },
307
+ reconnection: true,
308
+ reconnectionAttempts:20,
309
+ timeout:5000
310
+ });
311
+
312
+ const statusBox =
313
+ document.getElementById(
314
+ "status"
315
+ );
316
+
317
+ const statusText =
318
+ document.getElementById(
319
+ "statusText"
320
+ );
321
+
322
+
323
+ function setStatus(
324
+ text,
325
+ type
326
+ ){
327
+ statusBox.className =
328
+ "status " + type;
329
+ statusText.textContent =
330
+ text;
331
+
332
+ }
333
+
334
+
335
+
336
+
337
+ socket.on(
338
+ "connect",
339
+ () => {
340
+
341
+ setStatus(
342
+ "Connected",
343
+ "connected"
344
+ );
345
+
346
+
347
+ socket.emit(
348
+ "terminal-resize",
349
+ {
350
+ cols: term.cols,
351
+ rows: term.rows
352
+ }
353
+ );
354
+
355
+ }
356
+ );
357
+
358
+
359
+
360
+
361
+
362
+ socket.on(
363
+ "disconnect",
364
+ () => {
365
+
366
+ setStatus(
367
+ "Disconnected",
368
+ "disconnected"
369
+ );
370
+
371
+ }
372
+ );
373
+
374
+
375
+
376
+
377
+ socket.io.on(
378
+ "reconnect_attempt",
379
+ () => {
380
+
381
+ setStatus(
382
+ "Reconnecting",
383
+ "connecting"
384
+ );
385
+
386
+ }
387
+ );
388
+
389
+
390
+
391
+
392
+ socket.on(
393
+ "terminal-output",
394
+ data => {
395
+
396
+ term.write(data);
397
+
398
+ }
399
+ );
400
+
401
+
402
+
403
+
404
+
405
+ term.onData(
406
+ data => {
407
+
408
+ socket.emit(
409
+ "terminal-input",
410
+ data
411
+ );
412
+
413
+ }
414
+ );
415
+
416
+
417
+
418
+
419
+ window.addEventListener(
420
+ "resize",
421
+ () => {
422
+
423
+ fitAddon.fit();
424
+
425
+
426
+ socket.emit(
427
+ "terminal-resize",
428
+ {
429
+ cols: term.cols,
430
+ rows: term.rows
431
+ }
432
+ );
433
+
434
+ }
435
+ );
436
+
437
+
438
+
439
+
440
+
441
+ let seconds = 0;
442
+
443
+
444
+ setInterval(
445
+ () => {
446
+
447
+ if(!socket.connected)
448
+ return;
449
+
450
+
451
+ seconds++;
452
+
453
+
454
+ timer.textContent =
455
+ new Date(
456
+ seconds * 1000
457
+ )
458
+ .toISOString()
459
+ .substring(
460
+ 14,
461
+ 19
462
+ );
463
+
464
+
465
+ },
466
+ 1000
467
+ );
468
+
469
+
470
+
471
+
472
+
473
+ fullscreen.onclick =
474
+ () => {
475
+
476
+ document.documentElement
477
+ .requestFullscreen();
478
+
479
+ };
480
+ </script>
481
+ </body>
482
+ </html>
@@ -1,95 +0,0 @@
1
- const VIEWER_HTML = String.raw`<!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Terminal Session</title>
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
8
- <style>
9
- html,
10
- body {
11
- margin: 0;
12
- padding: 0;
13
- background: #050505;
14
- color: #f2f2f2;
15
- width: 100%;
16
- height: 100%;
17
- overflow: hidden;
18
- }
19
-
20
- #terminal {
21
- width: 100vw;
22
- height: 100vh;
23
- }
24
-
25
- .xterm {
26
- height: 100%;
27
- }
28
- </style>
29
- </head>
30
- <body>
31
- <div id="terminal"></div>
32
- <script src="/socket.io/socket.io.js"></script>
33
- <script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
34
- <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
35
- <script>
36
- const token = window.location.pathname.split('/').filter(Boolean).pop();
37
- const term = new Terminal({
38
- cursorBlink: true,
39
- convertEol: true,
40
- fontFamily: '"JetBrains Mono", "SFMono-Regular", Consolas, monospace',
41
- fontSize: 14,
42
- scrollback: 10000,
43
- theme: {
44
- background: '#050505',
45
- foreground: '#f4f4f5',
46
- cursor: '#f4f4f5',
47
- black: '#18181b',
48
- red: '#ef4444',
49
- green: '#22c55e',
50
- yellow: '#eab308',
51
- blue: '#3b82f6',
52
- magenta: '#a855f7',
53
- cyan: '#06b6d4',
54
- white: '#e4e4e7'
55
- }
56
- });
57
-
58
- const fitAddon = new FitAddon.FitAddon();
59
- term.loadAddon(fitAddon);
60
-
61
- term.open(document.getElementById('terminal'));
62
- fitAddon.fit();
63
-
64
- const socket = io({
65
- auth: { token },
66
- reconnectionAttempts: 10,
67
- timeout: 5000
68
- });
69
-
70
- window.addEventListener('resize', () => {
71
- fitAddon.fit();
72
- socket.emit('terminal-resize', { cols: term.cols, rows: term.rows });
73
- });
74
-
75
- socket.on('connect', () => {
76
- socket.emit('terminal-resize', { cols: term.cols, rows: term.rows });
77
- });
78
-
79
- socket.on('terminal-output', (data) => {
80
- term.write(data);
81
- });
82
-
83
- term.onData((data) => {
84
- socket.emit('terminal-input', data);
85
- });
86
-
87
- socket.on('terminal-exit', ({ exitCode, signal }) => {
88
- const suffix = signal ? 'signal ' + signal : 'exit code ' + exitCode;
89
- term.writeln('\r\n[Command ended: ' + suffix + ']');
90
- });
91
- </script>
92
- </body>
93
- </html>`;
94
-
95
- module.exports = { VIEWER_HTML };