9remote 2.0.2 → 2.0.8

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/cli/utils/tui.js DELETED
@@ -1,445 +0,0 @@
1
- /**
2
- * Terminal UI utilities — native ESM, no external deps
3
- * Provides: selectMenu(), renderProgress(), showBanner(), confirm()
4
- */
5
-
6
- import readline from "readline";
7
- import http from "http";
8
- import { openPermissionPane } from "./permissions.js";
9
-
10
- export { openPermissionPane };
11
-
12
- // Brand color: orange #E68A6E
13
- const C = {
14
- reset: "\x1b[0m",
15
- bold: "\x1b[1m",
16
- dim: "\x1b[2m",
17
- orange: "\x1b[38;2;230;138;110m",
18
- green: "\x1b[32m",
19
- red: "\x1b[31m",
20
- yellow: "\x1b[33m",
21
- cyan: "\x1b[36m",
22
- white: "\x1b[37m",
23
- };
24
-
25
- const W = () => Math.min(44, process.stdout.columns || 44);
26
-
27
- // ── Banner ────────────────────────────────────────────────────────────────────
28
-
29
- export function getBannerText(currentVersion, latestVersion = null) {
30
- const w = W();
31
- const inner = w - 2;
32
-
33
- const line = (text = "") => {
34
- const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
35
- const pad = Math.max(0, inner - plain.length);
36
- return C.orange + "║" + C.reset + text + " ".repeat(pad) + C.orange + "║" + C.reset;
37
- };
38
-
39
- const center = (text, colorFn = (s) => s) => {
40
- const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
41
- const lp = Math.floor((inner - plain.length) / 2);
42
- const rp = inner - plain.length - lp;
43
- return C.orange + "║" + C.reset + " ".repeat(lp) + colorFn(text) + " ".repeat(rp) + C.orange + "║" + C.reset;
44
- };
45
-
46
- const lines = [
47
- "",
48
- C.orange + "╔" + "═".repeat(inner) + "╗" + C.reset,
49
- line(),
50
- center(`🚀 9Remote v${currentVersion}`, (s) => C.bold + C.orange + s + C.reset),
51
- center("Remote terminal access from anywhere", (s) => C.dim + s + C.reset),
52
- line(),
53
- ];
54
-
55
- if (latestVersion) {
56
- lines.push(center(`⬆ New version v${latestVersion} available!`, (s) => C.yellow + C.bold + s + C.reset));
57
- lines.push(center(`Run: npm i -g 9remote@latest`, (s) => C.dim + s + C.reset));
58
- lines.push(line());
59
- }
60
-
61
- lines.push(C.orange + "╚" + "═".repeat(inner) + "╝" + C.reset);
62
- lines.push("");
63
- return lines.join("\n");
64
- }
65
-
66
- export function showBanner(currentVersion, latestVersion = null) {
67
- console.log(getBannerText(currentVersion, latestVersion));
68
- }
69
-
70
- // ── Progress ──────────────────────────────────────────────────────────────────
71
-
72
- const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
73
-
74
- const STEPS = [
75
- { label: "Preparing", desc: "Checking dependencies" },
76
- { label: "Connecting", desc: "Creating session" },
77
- { label: "Starting tunnel", desc: "Spawning tunnel" },
78
- { label: "Verifying tunnel", desc: "Health check" },
79
- { label: "Ready", desc: "Tunnel is live" },
80
- ];
81
-
82
- const IS_WIN = process.platform === "win32";
83
- const SPINNER_INTERVAL_MS = IS_WIN ? 120 : 80;
84
-
85
- // Ensure cursor is restored on any unexpected exit
86
- process.on("exit", () => process.stdout.write("\x1b[?25h"));
87
- process.on("SIGINT", () => { process.stdout.write("\x1b[?25h"); });
88
- process.on("SIGTERM", () => { process.stdout.write("\x1b[?25h"); });
89
-
90
- let _progressLines = 0;
91
- let _spinnerInterval = null;
92
- let _spinnerFrame = 0;
93
- let _activeIdx = -1;
94
- let _activeDesc = null;
95
- let _infoLine = null;
96
- let _cursorHidden = false;
97
-
98
- function _buildLines() {
99
- const lines = [];
100
- const isFinal = _activeIdx === STEPS.length - 1;
101
- STEPS.forEach((step, i) => {
102
- const desc = (i === _activeIdx && _activeDesc) ? _activeDesc : step.desc;
103
- if (i < _activeIdx || (isFinal && i === _activeIdx)) {
104
- lines.push(` ${C.green}✓${C.reset} ${C.dim}${step.label}${C.reset}`);
105
- if (_infoLine && i === _infoLine.afterIdx) {
106
- lines.push(` ${C.green}✓${C.reset} ${C.cyan}${_infoLine.text}${C.reset}`);
107
- }
108
- } else if (i === _activeIdx) {
109
- const frame = SPINNER_FRAMES[_spinnerFrame % SPINNER_FRAMES.length];
110
- lines.push(` ${C.orange}${frame}${C.reset} ${C.bold}${step.label}${C.reset} ${C.dim}${desc}${C.reset}`);
111
- } else {
112
- lines.push(` ${C.dim}○ ${step.label}${C.reset}`);
113
- }
114
- });
115
- return lines;
116
- }
117
-
118
- function _fullRedraw() {
119
- const lines = _buildLines();
120
- if (_progressLines > 0) {
121
- process.stdout.write(`\x1b[${_progressLines}A\x1b[0J`);
122
- }
123
- process.stdout.write(lines.join("\n") + "\n");
124
- _progressLines = lines.length;
125
- }
126
-
127
- // Only repaint the active spinner line to avoid flicker on Windows conhost
128
- function _tickSpinner() {
129
- if (_progressLines === 0 || _activeIdx < 0) return;
130
- const lines = _buildLines();
131
- if (lines.length !== _progressLines) {
132
- _fullRedraw();
133
- return;
134
- }
135
- const activeLineOffset = _activeIdx + (_infoLine && _infoLine.afterIdx < _activeIdx ? 1 : 0);
136
- const up = _progressLines - activeLineOffset;
137
- // Move up, clear line, write, move back down — single write = no flicker
138
- process.stdout.write(`\x1b[${up}A\r\x1b[2K${lines[activeLineOffset]}\x1b[${up}B\r`);
139
- }
140
-
141
- function _hideCursor() {
142
- if (!_cursorHidden) {
143
- process.stdout.write("\x1b[?25l");
144
- _cursorHidden = true;
145
- }
146
- }
147
-
148
- function _showCursor() {
149
- if (_cursorHidden) {
150
- process.stdout.write("\x1b[?25h");
151
- _cursorHidden = false;
152
- }
153
- }
154
-
155
- export function renderProgress(activeIdx, redraw = false, desc = null) {
156
- if (_spinnerInterval) {
157
- clearInterval(_spinnerInterval);
158
- _spinnerInterval = null;
159
- }
160
-
161
- _activeIdx = activeIdx;
162
- _activeDesc = desc;
163
- _spinnerFrame = 0;
164
-
165
- if (!redraw) _progressLines = 0;
166
- _fullRedraw();
167
-
168
- if (activeIdx < STEPS.length - 1) {
169
- _hideCursor();
170
- _spinnerInterval = setInterval(() => {
171
- _spinnerFrame++;
172
- _tickSpinner();
173
- }, SPINNER_INTERVAL_MS);
174
- } else {
175
- _showCursor();
176
- }
177
- }
178
-
179
- /** Show an extra info line after a completed step */
180
- export function setProgressInfo(afterIdx, text) {
181
- _infoLine = text ? { afterIdx, text } : null;
182
- }
183
-
184
- /** Update desc of current active step without changing step index */
185
- export function updateProgressDesc(desc) {
186
- _activeDesc = desc;
187
- if (_progressLines > 0) _tickSpinner();
188
- }
189
-
190
- export function resetProgress() {
191
- if (_spinnerInterval) {
192
- clearInterval(_spinnerInterval);
193
- _spinnerInterval = null;
194
- }
195
- _showCursor();
196
- _progressLines = 0;
197
- _activeIdx = -1;
198
- _activeDesc = null;
199
- _infoLine = null;
200
- _spinnerFrame = 0;
201
- }
202
-
203
- // ── selectMenu ────────────────────────────────────────────────────────────────
204
-
205
- /**
206
- * Interactive arrow-key menu. Clears full screen on each render.
207
- * Mirrors 9router_cli pattern exactly: emitKeypressEvents → setRawMode → on("keypress") → resume.
208
- * cleanup: setRawMode(false) → removeListener → pause.
209
- * Subsequent readline.createInterface calls work because they resume stdin internally.
210
- *
211
- * @param {string} title
212
- * @param {Array<{label: string}>} items
213
- * @param {number} defaultIndex
214
- * @param {string} headerContent — pre-built string shown above menu
215
- * @param {(setRedraw: () => void, forceExit?: () => void) => void} onRedrawInit — receive redraw + forceExit triggers (for SSE updates / external prompts)
216
- * @returns {Promise<number>} selected index, -1 on ESC, -2 on forceExit (caller should re-render)
217
- */
218
- export function selectMenu(title, items, defaultIndex = 0, headerContent = "", onRedrawInit = null, onCtrlC = null) {
219
- return new Promise((resolve) => {
220
- let selected = defaultIndex;
221
- let isActive = true;
222
- const isWin = process.platform === "win32";
223
-
224
- const renderMenu = () => {
225
- if (!isActive) return;
226
- process.stdout.write("\x1b[2J\x1b[H");
227
- // Support both static string and dynamic getter function
228
- const header = typeof headerContent === "function" ? headerContent() : headerContent;
229
- if (header) {
230
- process.stdout.write(header + "\n");
231
- }
232
- if (title) process.stdout.write(`${C.dim}${title}${C.reset}\n\n`);
233
- items.forEach((item, i) => {
234
- const icon = i === selected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
235
- if (i === selected) {
236
- console.log(` \x1b[7m${C.bold}${icon} ${item.label}${C.reset}`);
237
- } else {
238
- console.log(` ${icon} ${item.label}`);
239
- }
240
- });
241
- };
242
-
243
- const cleanup = () => {
244
- if (!isActive) return;
245
- isActive = false;
246
- if (process.stdin.isTTY) {
247
- try { process.stdin.setRawMode(false); } catch {}
248
- }
249
- process.stdin.removeListener("keypress", onKeypress);
250
- process.stdin.pause();
251
- };
252
-
253
- const onKeypress = (str, key) => {
254
- if (!isActive || !key) return;
255
- if (key.name === "up") {
256
- selected = (selected - 1 + items.length) % items.length;
257
- renderMenu();
258
- } else if (key.name === "down") {
259
- selected = (selected + 1) % items.length;
260
- renderMenu();
261
- } else if (key.name === "return") {
262
- cleanup();
263
- resolve(selected);
264
- } else if (key.name === "escape") {
265
- cleanup();
266
- resolve(-1);
267
- } else if (key.ctrl && key.name === "c") {
268
- cleanup();
269
- if (onCtrlC) onCtrlC();
270
- process.exit(0);
271
- }
272
- };
273
-
274
- // Exact same order as 9router_cli
275
- process.stdin.removeAllListeners("keypress");
276
- readline.emitKeypressEvents(process.stdin);
277
- if (process.stdin.isTTY) {
278
- try { process.stdin.setRawMode(true); } catch { resolve(-1); return; }
279
- }
280
- process.stdin.on("keypress", onKeypress);
281
- process.stdin.resume();
282
- renderMenu();
283
-
284
- // Allow external code to force-exit this menu (e.g. to show a prompt that needs stdin).
285
- // Resolves with -2 so caller knows to re-render/restart the menu with a fresh stdin state.
286
- const forceExit = () => {
287
- if (!isActive) return;
288
- cleanup();
289
- resolve(-2);
290
- };
291
-
292
- // Allow external code (SSE) to trigger a re-render without disrupting navigation
293
- if (onRedrawInit) onRedrawInit(renderMenu, forceExit);
294
- });
295
- }
296
-
297
- // ── confirm ───────────────────────────────────────────────────────────────────
298
-
299
- /**
300
- * Yes/no prompt. Uses readline.createInterface which resumes stdin internally.
301
- * Works after selectMenu.cleanup() which pauses stdin.
302
- */
303
- export function confirm(message) {
304
- return new Promise((resolve) => {
305
- // Clean state
306
- process.stdin.removeAllListeners("keypress");
307
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
308
- process.stdin.pause();
309
-
310
- process.stdout.write(`${message} (y/N): `);
311
-
312
- // Use raw keypress (same pattern as selectMenu)
313
- readline.emitKeypressEvents(process.stdin);
314
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
315
- process.stdin.resume();
316
-
317
- const onKeypress = (str, key) => {
318
- if (!key) return;
319
- process.stdin.removeListener("keypress", onKeypress);
320
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
321
- process.stdin.pause();
322
-
323
- if (key.ctrl && key.name === "c") { process.stdout.write("\n"); process.exit(0); }
324
- const approved = (key.name || "").toLowerCase() === "y";
325
- console.log(approved ? "y" : "n");
326
- resolve(approved);
327
- };
328
-
329
- process.stdin.on("keypress", onKeypress);
330
- });
331
- }
332
-
333
- // ── Device Approval Prompt ────────────────────────────────────────────────────
334
-
335
- /**
336
- * Show device approval prompt with raw-mode single-key capture.
337
- * Fully takes over stdin from selectMenu, resolves with true/false.
338
- */
339
- export function showDeviceApproval(deviceId, ip) {
340
- return new Promise((resolve) => {
341
- const shortId = deviceId ? deviceId.slice(0, 8) : "unknown";
342
- const w = W();
343
-
344
- // Save existing keypress listeners (e.g. selectMenu's) so we can restore
345
- // them after the prompt — otherwise the caller's menu loses arrow-key input.
346
- const savedListeners = process.stdin.listeners("keypress").slice();
347
- process.stdin.removeAllListeners("keypress");
348
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
349
- process.stdin.pause();
350
-
351
- // Clear screen for clean approval UI
352
- process.stdout.write("\x1b[2J\x1b[H");
353
- console.log("");
354
- console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
355
- console.log(`${C.orange}${C.bold} 🔔 New Device Connection${C.reset}`);
356
- console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
357
- console.log(` Device: ${C.cyan}${shortId}...${C.reset}`);
358
- console.log(` IP: ${C.cyan}${ip}${C.reset}`);
359
- console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
360
- console.log("");
361
-
362
- process.stdout.write(` Allow this device? ${C.dim}(y/n)${C.reset} `);
363
-
364
- // Use keypress events (same pattern as selectMenu)
365
- readline.emitKeypressEvents(process.stdin);
366
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
367
- process.stdin.resume();
368
-
369
- const restoreListeners = () => {
370
- for (const l of savedListeners) process.stdin.on("keypress", l);
371
- if (savedListeners.length > 0) {
372
- // Previous owner (selectMenu) was in raw mode + resumed stdin.
373
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
374
- process.stdin.resume();
375
- }
376
- };
377
-
378
- const onKeypress = (str, key) => {
379
- if (!key) return;
380
- const ch = (key.name || "").toLowerCase();
381
- if (ch === "y" || ch === "n" || key.name === "return" || (key.ctrl && key.name === "c")) {
382
- process.stdin.removeListener("keypress", onKeypress);
383
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
384
- process.stdin.pause();
385
- if (key.ctrl && key.name === "c") process.exit(0);
386
-
387
- const approved = ch === "y";
388
- console.log(approved ? `${C.green}y${C.reset}` : `${C.red}n${C.reset}`);
389
- console.log(approved
390
- ? `\n ${C.green}\u2713 Device approved${C.reset}`
391
- : `\n ${C.red}\u2717 Device rejected${C.reset}`);
392
- setTimeout(() => { restoreListeners(); resolve(approved); }, 500);
393
- }
394
- };
395
-
396
- process.stdin.on("keypress", onKeypress);
397
- });
398
- }
399
-
400
- // ── SSE client ───────────────────────────────────────────────────────────────
401
-
402
- /**
403
- * Subscribe to server SSE stream. Calls onEvent(type, data) for each event.
404
- * Returns a cleanup function to close the connection.
405
- * @param {number} port
406
- * @param {(type: string, data: object) => void} onEvent
407
- * @returns {() => void} cleanup
408
- */
409
- export function subscribeSSE(port, onEvent) {
410
- let req = null;
411
- let closed = false;
412
-
413
- const connect = () => {
414
- if (closed) return;
415
- req = http.get(`http://localhost:${port}/api/ui/events`, (res) => {
416
- let buf = "";
417
- res.on("data", (chunk) => {
418
- buf += chunk.toString();
419
- const lines = buf.split("\n");
420
- buf = lines.pop(); // keep incomplete line
421
- let eventData = "";
422
- for (const line of lines) {
423
- if (line.startsWith("data: ")) {
424
- eventData = line.slice(6);
425
- } else if (line === "" && eventData) {
426
- try {
427
- const parsed = JSON.parse(eventData);
428
- onEvent(parsed.type, parsed);
429
- } catch {}
430
- eventData = "";
431
- }
432
- }
433
- });
434
- res.on("end", () => {
435
- if (!closed) setTimeout(connect, 2000); // reconnect
436
- });
437
- });
438
- req.on("error", () => {
439
- if (!closed) setTimeout(connect, 2000); // reconnect on error
440
- });
441
- };
442
-
443
- connect();
444
- return () => { closed = true; req?.destroy(); };
445
- }