9remote 2.0.1 → 2.0.7

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,426 +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) => void} onRedrawInit — receive a redraw trigger fn (for SSE updates)
216
- * @returns {Promise<number>} selected index, -1 on ESC
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 (SSE) to trigger a re-render without disrupting navigation
285
- if (onRedrawInit) onRedrawInit(renderMenu);
286
- });
287
- }
288
-
289
- // ── confirm ───────────────────────────────────────────────────────────────────
290
-
291
- /**
292
- * Yes/no prompt. Uses readline.createInterface which resumes stdin internally.
293
- * Works after selectMenu.cleanup() which pauses stdin.
294
- */
295
- export function confirm(message) {
296
- return new Promise((resolve) => {
297
- // Clean state
298
- process.stdin.removeAllListeners("keypress");
299
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
300
- process.stdin.pause();
301
-
302
- process.stdout.write(`${message} (y/N): `);
303
-
304
- // Use raw keypress (same pattern as selectMenu)
305
- readline.emitKeypressEvents(process.stdin);
306
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
307
- process.stdin.resume();
308
-
309
- const onKeypress = (str, key) => {
310
- if (!key) return;
311
- process.stdin.removeListener("keypress", onKeypress);
312
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
313
- process.stdin.pause();
314
-
315
- if (key.ctrl && key.name === "c") { process.stdout.write("\n"); process.exit(0); }
316
- const approved = (key.name || "").toLowerCase() === "y";
317
- console.log(approved ? "y" : "n");
318
- resolve(approved);
319
- };
320
-
321
- process.stdin.on("keypress", onKeypress);
322
- });
323
- }
324
-
325
- // ── Device Approval Prompt ────────────────────────────────────────────────────
326
-
327
- /**
328
- * Show device approval prompt with raw-mode single-key capture.
329
- * Fully takes over stdin from selectMenu, resolves with true/false.
330
- */
331
- export function showDeviceApproval(deviceId, ip) {
332
- return new Promise((resolve) => {
333
- const shortId = deviceId ? deviceId.slice(0, 8) : "unknown";
334
- const w = W();
335
-
336
- // Fully take over stdin from selectMenu
337
- process.stdin.removeAllListeners("keypress");
338
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
339
- process.stdin.pause();
340
-
341
- // Clear screen for clean approval UI
342
- process.stdout.write("\x1b[2J\x1b[H");
343
- console.log("");
344
- console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
345
- console.log(`${C.orange}${C.bold} 🔔 New Device Connection${C.reset}`);
346
- console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
347
- console.log(` Device: ${C.cyan}${shortId}...${C.reset}`);
348
- console.log(` IP: ${C.cyan}${ip}${C.reset}`);
349
- console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
350
- console.log("");
351
-
352
- process.stdout.write(` Allow this device? ${C.dim}(y/n)${C.reset} `);
353
-
354
- // Use keypress events (same pattern as selectMenu)
355
- readline.emitKeypressEvents(process.stdin);
356
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
357
- process.stdin.resume();
358
-
359
- const onKeypress = (str, key) => {
360
- if (!key) return;
361
- const ch = (key.name || "").toLowerCase();
362
- if (ch === "y" || ch === "n" || key.name === "return" || (key.ctrl && key.name === "c")) {
363
- process.stdin.removeListener("keypress", onKeypress);
364
- if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
365
- process.stdin.pause();
366
- if (key.ctrl && key.name === "c") process.exit(0);
367
-
368
- const approved = ch === "y";
369
- console.log(approved ? `${C.green}y${C.reset}` : `${C.red}n${C.reset}`);
370
- console.log(approved
371
- ? `\n ${C.green}\u2713 Device approved${C.reset}`
372
- : `\n ${C.red}\u2717 Device rejected${C.reset}`);
373
- setTimeout(() => resolve(approved), 500);
374
- }
375
- };
376
-
377
- process.stdin.on("keypress", onKeypress);
378
- });
379
- }
380
-
381
- // ── SSE client ───────────────────────────────────────────────────────────────
382
-
383
- /**
384
- * Subscribe to server SSE stream. Calls onEvent(type, data) for each event.
385
- * Returns a cleanup function to close the connection.
386
- * @param {number} port
387
- * @param {(type: string, data: object) => void} onEvent
388
- * @returns {() => void} cleanup
389
- */
390
- export function subscribeSSE(port, onEvent) {
391
- let req = null;
392
- let closed = false;
393
-
394
- const connect = () => {
395
- if (closed) return;
396
- req = http.get(`http://localhost:${port}/api/ui/events`, (res) => {
397
- let buf = "";
398
- res.on("data", (chunk) => {
399
- buf += chunk.toString();
400
- const lines = buf.split("\n");
401
- buf = lines.pop(); // keep incomplete line
402
- let eventData = "";
403
- for (const line of lines) {
404
- if (line.startsWith("data: ")) {
405
- eventData = line.slice(6);
406
- } else if (line === "" && eventData) {
407
- try {
408
- const parsed = JSON.parse(eventData);
409
- onEvent(parsed.type, parsed);
410
- } catch {}
411
- eventData = "";
412
- }
413
- }
414
- });
415
- res.on("end", () => {
416
- if (!closed) setTimeout(connect, 2000); // reconnect
417
- });
418
- });
419
- req.on("error", () => {
420
- if (!closed) setTimeout(connect, 2000); // reconnect on error
421
- });
422
- };
423
-
424
- connect();
425
- return () => { closed = true; req?.destroy(); };
426
- }