9remote 0.1.63 → 2.0.1

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 CHANGED
@@ -26,7 +26,7 @@ const W = () => Math.min(44, process.stdout.columns || 44);
26
26
 
27
27
  // ── Banner ────────────────────────────────────────────────────────────────────
28
28
 
29
- export function showBanner(currentVersion, latestVersion = null) {
29
+ export function getBannerText(currentVersion, latestVersion = null) {
30
30
  const w = W();
31
31
  const inner = w - 2;
32
32
 
@@ -43,58 +43,161 @@ export function showBanner(currentVersion, latestVersion = null) {
43
43
  return C.orange + "║" + C.reset + " ".repeat(lp) + colorFn(text) + " ".repeat(rp) + C.orange + "║" + C.reset;
44
44
  };
45
45
 
46
- console.log("");
47
- console.log(C.orange + "" + "═".repeat(inner) + "╗" + C.reset);
48
- console.log(line());
49
- console.log(center(`🚀 9Remote v${currentVersion}`, (s) => C.bold + C.orange + s + C.reset));
50
- console.log(center("Remote terminal access from anywhere", (s) => C.dim + s + C.reset));
51
- console.log(line());
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
+ ];
52
54
 
53
55
  if (latestVersion) {
54
- const notice = `⬆ New version v${latestVersion} available!`;
55
- console.log(center(notice, (s) => C.yellow + C.bold + s + C.reset));
56
- const hint = `Run: npm i -g 9remote@latest`;
57
- console.log(center(hint, (s) => C.dim + s + C.reset));
58
- console.log(line());
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
59
  }
60
60
 
61
- console.log(C.orange + "╚" + "═".repeat(inner) + "╝" + C.reset);
62
- console.log("");
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));
63
68
  }
64
69
 
65
70
  // ── Progress ──────────────────────────────────────────────────────────────────
66
71
 
72
+ const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
73
+
67
74
  const STEPS = [
68
- { label: "Preparing", desc: "Checking dependencies" },
69
- { label: "Connecting", desc: "Creating session" },
70
- { label: "Starting tunnel", desc: "Spawning cloudflared" },
71
- { label: "Ready", desc: "Tunnel is live" },
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" },
72
80
  ];
73
81
 
74
- let _progressLines = 0;
82
+ const IS_WIN = process.platform === "win32";
83
+ const SPINNER_INTERVAL_MS = IS_WIN ? 120 : 80;
75
84
 
76
- export function renderProgress(activeIdx, redraw = false) {
77
- if (redraw && _progressLines > 0) {
78
- process.stdout.write(`\x1b[${_progressLines}A\x1b[0J`);
79
- }
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"); });
80
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() {
81
99
  const lines = [];
100
+ const isFinal = _activeIdx === STEPS.length - 1;
82
101
  STEPS.forEach((step, i) => {
83
- if (i < activeIdx) {
102
+ const desc = (i === _activeIdx && _activeDesc) ? _activeDesc : step.desc;
103
+ if (i < _activeIdx || (isFinal && i === _activeIdx)) {
84
104
  lines.push(` ${C.green}✓${C.reset} ${C.dim}${step.label}${C.reset}`);
85
- } else if (i === activeIdx) {
86
- lines.push(` ${C.orange}●${C.reset} ${C.bold}${step.label}${C.reset} ${C.dim}${step.desc}${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}`);
87
111
  } else {
88
112
  lines.push(` ${C.dim}○ ${step.label}${C.reset}`);
89
113
  }
90
114
  });
115
+ return lines;
116
+ }
91
117
 
92
- lines.forEach((l) => console.log(l));
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");
93
124
  _progressLines = lines.length;
94
125
  }
95
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
+
96
190
  export function resetProgress() {
191
+ if (_spinnerInterval) {
192
+ clearInterval(_spinnerInterval);
193
+ _spinnerInterval = null;
194
+ }
195
+ _showCursor();
97
196
  _progressLines = 0;
197
+ _activeIdx = -1;
198
+ _activeDesc = null;
199
+ _infoLine = null;
200
+ _spinnerFrame = 0;
98
201
  }
99
202
 
100
203
  // ── selectMenu ────────────────────────────────────────────────────────────────
@@ -126,7 +229,7 @@ export function selectMenu(title, items, defaultIndex = 0, headerContent = "", o
126
229
  if (header) {
127
230
  process.stdout.write(header + "\n");
128
231
  }
129
- process.stdout.write(`${C.dim}${title}${C.reset}\n\n`);
232
+ if (title) process.stdout.write(`${C.dim}${title}${C.reset}\n\n`);
130
233
  items.forEach((item, i) => {
131
234
  const icon = i === selected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
132
235
  if (i === selected) {
@@ -191,15 +294,87 @@ export function selectMenu(title, items, defaultIndex = 0, headerContent = "", o
191
294
  */
192
295
  export function confirm(message) {
193
296
  return new Promise((resolve) => {
194
- // Ensure clean state
297
+ // Clean state
298
+ process.stdin.removeAllListeners("keypress");
195
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
196
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("");
197
351
 
198
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
199
- rl.question(`${message} (y/N): `, (answer) => {
200
- rl.close();
201
- resolve(answer.trim().toLowerCase() === "y");
202
- });
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);
203
378
  });
204
379
  }
205
380
 
@@ -1,15 +1,63 @@
1
1
  import chalk from "chalk";
2
- import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
3
3
  import { fileURLToPath } from "url";
4
- import { spawn } from "child_process";
4
+ import { spawn, execSync } from "child_process";
5
5
  import path from "path";
6
6
  import os from "os";
7
+ import { browserFetch } from "../../lib/constants.js";
8
+ import { killAll as killAllPids, getPidsDir } from "./pids.js";
7
9
 
8
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
11
  const PACKAGE_NAME = "9remote";
10
12
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
11
13
  const UPDATE_CHECK_TIMEOUT = 3000;
12
14
  const SAFETY_TIMEOUT = 8000;
15
+ const SERVER_PORT = 2208;
16
+ // Legacy path from pre-pids.js installs — clean up once so old instances
17
+ // can still be killed during upgrade from older versions.
18
+ const LEGACY_CLOUDFLARED_PID_FILE = path.join(os.homedir(), ".9remote", "cloudflared.pid");
19
+
20
+ /**
21
+ * Kill all 9remote-related child processes to release file locks before npm install.
22
+ *
23
+ * Critical on Windows: node.exe / tray helper / cloudflared.exe lock files inside
24
+ * node_modules\9remote\dist, so `npm i -g` fails with EBUSY when trying to rename
25
+ * the old dist folder.
26
+ *
27
+ * We kill ONLY by PID (from ~/.9remote/pids/) — never by image name (taskkill /IM)
28
+ * or by commandline match, because those would also kill unrelated apps on the
29
+ * machine that happen to use cloudflared.exe, tray_windows_release.exe, or node.exe.
30
+ */
31
+ function cleanupBeforeUpdate() {
32
+ // 1. Kill tracked processes (cloudflared + agent tree) via pids.js.
33
+ // Agent kill with taskkill /F /T sweeps its server child + tray helper.
34
+ try { killAllPids(); } catch {}
35
+
36
+ // 2. Legacy: older versions stored cloudflared PID at a different path.
37
+ try {
38
+ if (existsSync(LEGACY_CLOUDFLARED_PID_FILE)) {
39
+ const pid = parseInt(readFileSync(LEGACY_CLOUDFLARED_PID_FILE, "utf8"));
40
+ if (Number.isFinite(pid)) { try { process.kill(pid); } catch {} }
41
+ try { unlinkSync(LEGACY_CLOUDFLARED_PID_FILE); } catch {}
42
+ }
43
+ } catch {}
44
+
45
+ // 3. Safety net for stale server on SERVER_PORT (e.g. crashed without clearing PID).
46
+ // Scoped to one specific port, so we only touch 9remote's own server.
47
+ try {
48
+ if (process.platform === "win32") {
49
+ execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${SERVER_PORT}') do taskkill /F /PID %a`, { stdio: "ignore", windowsHide: true });
50
+ } else {
51
+ execSync(`lsof -ti:${SERVER_PORT} | xargs kill -9 2>/dev/null || true`, { stdio: "ignore" });
52
+ }
53
+ } catch {}
54
+
55
+ // 4. Give Windows a moment to release file handles before npm tries to rename.
56
+ if (process.platform === "win32") {
57
+ const end = Date.now() + 1500;
58
+ while (Date.now() < end) { /* spin-wait; Atomics.wait not worth importing */ }
59
+ }
60
+ }
13
61
 
14
62
  /**
15
63
  * Get current version
@@ -34,7 +82,7 @@ function getCurrentVersion() {
34
82
  * Compare semver versions
35
83
  * Returns true if latest > current
36
84
  */
37
- function isNewerVersion(current, latest) {
85
+ export function isNewerVersion(current, latest) {
38
86
  const currentParts = current.split(".").map(Number);
39
87
  const latestParts = latest.split(".").map(Number);
40
88
 
@@ -61,12 +109,20 @@ function isRestrictedEnvironment() {
61
109
  /**
62
110
  * Check for npm updates (non-blocking, notification only)
63
111
  */
112
+ /**
113
+ * Kill running 9remote processes so user can safely run `npm i -g 9remote@latest`.
114
+ * Called when user chooses manual update from startup menu.
115
+ */
116
+ export function stopRunningInstances() {
117
+ cleanupBeforeUpdate();
118
+ }
119
+
64
120
  export async function checkForUpdates() {
65
121
  try {
66
122
  const currentVersion = getCurrentVersion();
67
123
  if (!currentVersion) return;
68
124
 
69
- const response = await fetch(NPM_REGISTRY_URL, {
125
+ const response = await browserFetch(NPM_REGISTRY_URL, {
70
126
  signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT)
71
127
  });
72
128
 
@@ -94,7 +150,7 @@ export async function checkLatestVersion() {
94
150
  try {
95
151
  const currentVersion = getCurrentVersion();
96
152
  if (!currentVersion) return null;
97
- const response = await fetch(NPM_REGISTRY_URL, {
153
+ const response = await browserFetch(NPM_REGISTRY_URL, {
98
154
  signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT)
99
155
  });
100
156
  if (!response.ok) return null;
@@ -158,7 +214,7 @@ export async function checkAndUpdate(skipUpdate = false) {
158
214
 
159
215
  startSpinner("Checking for updates...");
160
216
 
161
- fetch(NPM_REGISTRY_URL, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT) })
217
+ browserFetch(NPM_REGISTRY_URL, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT) })
162
218
  .then((res) => res.json())
163
219
  .then((data) => {
164
220
  if (resolved) return;
@@ -191,11 +247,33 @@ export async function checkAndUpdate(skipUpdate = false) {
191
247
 
192
248
  let scriptPath, shellCmd;
193
249
 
250
+ // The update script must kill our tracked processes by PID ONLY.
251
+ // Never use `taskkill /IM <image>` or `pkill -f <name>` — those match
252
+ // by binary name / commandline and would nuke unrelated apps on the
253
+ // machine (other cloudflared tunnels, other tray apps, any node.exe).
254
+ const pidsDir = getPidsDir();
255
+
194
256
  if (platform === "win32") {
195
257
  const script = `@echo off
196
258
  echo 📥 Downloading update...
197
- echo ⏳ Waiting for process to exit...
198
- timeout /t 2 /nobreak >nul
259
+ echo ⏳ Stopping 9remote processes...
260
+
261
+ REM Kill cloudflared first so it stops reconnecting, then kill the agent
262
+ REM tree (/T also terminates its server child + tray helper). PID-based so
263
+ REM we never touch unrelated node.exe / cloudflared.exe on this machine.
264
+ REM ptyDaemon is intentionally NOT killed — keep terminal sessions alive.
265
+ for %%N in (cloudflared agent) do (
266
+ if exist "${pidsDir}\\%%N.pid" (
267
+ for /f %%P in ('type "${pidsDir}\\%%N.pid"') do taskkill /F /T /PID %%P >nul 2>&1
268
+ del /f /q "${pidsDir}\\%%N.pid" >nul 2>&1
269
+ )
270
+ )
271
+
272
+ REM Safety net: kill anything still bound to our server port.
273
+ for /f "tokens=5" %%a in ('netstat -aon ^| findstr :${SERVER_PORT}') do taskkill /F /PID %%a >nul 2>&1
274
+
275
+ REM Let Windows flush file handles before npm renames node_modules\\9remote.
276
+ timeout /t 3 /nobreak >nul
199
277
 
200
278
  echo 🔄 Installing new version...
201
279
  call npm cache clean --force >nul 2>&1
@@ -218,11 +296,24 @@ if %ERRORLEVEL% EQU 0 (
218
296
  } else {
219
297
  const script = `#!/bin/bash
220
298
  echo "📥 Downloading update..."
221
- echo "⏳ Waiting for process to exit..."
222
- sleep 1
223
-
224
- pkill -f "${PACKAGE_NAME}" 2>/dev/null || true
225
- sleep 1
299
+ echo "⏳ Stopping 9remote processes..."
300
+
301
+ # Kill cloudflared first, then the agent (children die with the agent on
302
+ # POSIX once the parent process exits and systemd/init reaps them, or here
303
+ # because we SIGKILL them via kill -9). PID-based so unrelated apps are safe.
304
+ # ptyDaemon is intentionally NOT killed — keep terminal sessions alive.
305
+ for name in cloudflared agent; do
306
+ f="${pidsDir}/\${name}.pid"
307
+ if [ -f "$f" ]; then
308
+ pid=$(cat "$f" 2>/dev/null)
309
+ [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
310
+ rm -f "$f"
311
+ fi
312
+ done
313
+
314
+ # Safety net: kill anything still bound to our server port.
315
+ lsof -ti:${SERVER_PORT} | xargs kill -9 2>/dev/null || true
316
+ sleep 2
226
317
 
227
318
  echo "🔄 Installing new version..."
228
319
  npm cache clean --force 2>/dev/null
@@ -247,6 +338,9 @@ fi
247
338
  shellCmd = ["sh", [scriptPath]];
248
339
  }
249
340
 
341
+ // Cleanup child processes to release file locks before npm install
342
+ cleanupBeforeUpdate();
343
+
250
344
  // Execute update script in background
251
345
  const child = spawn(shellCmd[0], shellCmd[1], {
252
346
  detached: true,
Binary file