9remote 0.1.52 → 0.1.54

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.
@@ -3,38 +3,39 @@ import crypto from "crypto";
3
3
  const API_KEY_SECRET = process.env.API_KEY_SECRET || "9remote-api-key-secret";
4
4
 
5
5
  /**
6
- * Generate 6-char random keyId
6
+ * Generate 4-char random keyId
7
7
  */
8
8
  function generateKeyId() {
9
9
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
10
10
  let result = "";
11
- for (let i = 0; i < 6; i++) {
11
+ for (let i = 0; i < 4; i++) {
12
12
  result += chars.charAt(Math.floor(Math.random() * chars.length));
13
13
  }
14
14
  return result;
15
15
  }
16
16
 
17
17
  /**
18
- * Generate CRC (8-char HMAC)
18
+ * Generate CRC (6-char HMAC)
19
19
  */
20
20
  function generateCrc(machineId, keyId) {
21
21
  return crypto
22
22
  .createHmac("sha256", API_KEY_SECRET)
23
23
  .update(machineId + keyId)
24
24
  .digest("hex")
25
- .slice(0, 8);
25
+ .slice(0, 6);
26
26
  }
27
27
 
28
28
  /**
29
29
  * Generate API key with machineId embedded
30
- * Format: sk-{machineId}-{keyId}-{crc8}
31
- * @param {string} machineId - 16-char machine ID
30
+ * Format: sk-{machineId8}-{keyId4}-{crc6}
31
+ * @param {string} machineId - machine ID (uses first 8 chars)
32
32
  * @returns {{ key: string, keyId: string }}
33
33
  */
34
34
  export function generateApiKeyWithMachine(machineId) {
35
+ const shortId = machineId.slice(0, 8);
35
36
  const keyId = generateKeyId();
36
- const crc = generateCrc(machineId, keyId);
37
- const key = `sk-${machineId}-${keyId}-${crc}`;
37
+ const crc = generateCrc(shortId, keyId);
38
+ const key = `sk-${shortId}-${keyId}-${crc}`;
38
39
  return { key, keyId };
39
40
  }
40
41
 
@@ -61,6 +62,10 @@ export function parseApiKey(apiKey) {
61
62
  return null;
62
63
  }
63
64
 
65
+ /**
66
+ * Verify API key CRC — supports both old (16+6+8) and new (8+4+6) format
67
+ */
68
+
64
69
  /**
65
70
  * Verify API key CRC
66
71
  * @param {string} apiKey
@@ -163,6 +163,112 @@ const LOG_IGNORE = [
163
163
  "Updated to new configuration"
164
164
  ];
165
165
 
166
+ /**
167
+ * Parse trycloudflare.com URL from cloudflared log output
168
+ */
169
+ function parseQuickTunnelUrl(message) {
170
+ const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi;
171
+ const candidates = [];
172
+ for (const match of message.matchAll(regex)) {
173
+ if (match[1] === "api") continue;
174
+ candidates.push(`https://${match[1]}.trycloudflare.com`);
175
+ }
176
+ return candidates.length ? candidates[candidates.length - 1] : null;
177
+ }
178
+
179
+ /**
180
+ * Spawn cloudflared quick tunnel (no account needed)
181
+ * @param {number} localPort - Local port to tunnel
182
+ * @param {Function} onUrlUpdate - Called when URL changes after initial connect
183
+ * @returns {Promise<{child, tunnelUrl}>}
184
+ */
185
+ export async function spawnQuickTunnel(localPort, onUrlUpdate = null) {
186
+ const binaryPath = await ensureCloudflared();
187
+
188
+ // Use temp config to avoid conflicting with ~/.cloudflared/config.yml
189
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "9remote-quick-"));
190
+ const configPath = path.join(configDir, "config.yml");
191
+ fs.writeFileSync(configPath, "# quick-tunnel\n", "utf8");
192
+
193
+ let cleaned = false;
194
+ const cleanup = () => {
195
+ if (cleaned) return;
196
+ cleaned = true;
197
+ try { fs.rmSync(configDir, { recursive: true, force: true }); } catch { }
198
+ };
199
+
200
+ const child = spawn(
201
+ binaryPath,
202
+ ["tunnel", "--url", `http://localhost:${localPort}`, "--config", configPath, "--no-autoupdate"],
203
+ { detached: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }
204
+ );
205
+
206
+ fs.writeFileSync(PID_FILE, child.pid.toString());
207
+ isIntentionalShutdown = false;
208
+
209
+ return new Promise((resolve, reject) => {
210
+ let resolved = false;
211
+ let lastUrl = null;
212
+
213
+ const timeout = setTimeout(() => {
214
+ if (resolved) return;
215
+ resolved = true;
216
+ cleanup();
217
+ reject(new Error("Quick tunnel timed out after 90s"));
218
+ }, 90000);
219
+
220
+ const handleLog = (data) => {
221
+ const msg = data.toString();
222
+ const tunnelUrl = parseQuickTunnelUrl(msg);
223
+ if (!tunnelUrl) return;
224
+
225
+ if (!resolved) {
226
+ resolved = true;
227
+ lastUrl = tunnelUrl;
228
+ clearTimeout(timeout);
229
+ cleanup();
230
+ resolve({ child, tunnelUrl });
231
+ return;
232
+ }
233
+
234
+ // URL rotated after initial connect — notify caller
235
+ if (tunnelUrl !== lastUrl) {
236
+ lastUrl = tunnelUrl;
237
+ onUrlUpdate?.(tunnelUrl);
238
+ }
239
+ };
240
+
241
+ child.stdout.on("data", handleLog);
242
+ child.stderr.on("data", handleLog);
243
+
244
+ child.on("error", (err) => {
245
+ if (resolved) return;
246
+ resolved = true;
247
+ clearTimeout(timeout);
248
+ cleanup();
249
+ reject(err);
250
+ });
251
+
252
+ child.on("exit", (code) => {
253
+ cleanup();
254
+ if (!resolved) {
255
+ resolved = true;
256
+ clearTimeout(timeout);
257
+ reject(new Error(`cloudflared exited with code ${code}`));
258
+ return;
259
+ }
260
+ if (!isIntentionalShutdown && restartCallback) {
261
+ const now = Date.now();
262
+ restartTimes.push(now);
263
+ restartTimes = restartTimes.filter(t => t > now - RESTART_WINDOW_MS);
264
+ if (restartTimes.length <= MAX_RESTART_ATTEMPTS) {
265
+ setTimeout(() => restartCallback(localPort), 2000);
266
+ }
267
+ }
268
+ });
269
+ });
270
+ }
271
+
166
272
  /**
167
273
  * Spawn cloudflared tunnel
168
274
  * @param {string} tunnelToken
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared permission check logic (macOS TCC).
3
+ * Used by both the server (index.js) and TUI (tui.js via API).
4
+ */
5
+
6
+ import { exec } from "child_process";
7
+
8
+ export const PERM_URLS = {
9
+ screenRecording: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
10
+ accessibility: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
11
+ };
12
+
13
+ /**
14
+ * Check macOS permissions. Returns { screenRecording, accessibility }.
15
+ * On non-macOS always returns true for both.
16
+ * @returns {Promise<{screenRecording: boolean, accessibility: boolean}>}
17
+ */
18
+ export function checkPermissions() {
19
+ return new Promise((resolve) => {
20
+ if (process.platform !== "darwin") {
21
+ resolve({ screenRecording: true, accessibility: true });
22
+ return;
23
+ }
24
+
25
+ let sr = false, ax = false, done = 0;
26
+ const finish = () => { if (++done === 2) resolve({ screenRecording: sr, accessibility: ax }); };
27
+
28
+ // Accessibility: attempt a real keystroke action — fails without permission
29
+ exec(`osascript -e 'tell application "System Events" to key code 0 using {}'`,
30
+ { timeout: 3000 }, (err) => { ax = !err; finish(); });
31
+
32
+ // Screen Recording: capture 1px — fails silently without permission
33
+ exec(`screencapture -x -R 0,0,1,1 /tmp/9remote_perm_check.png && rm -f /tmp/9remote_perm_check.png`,
34
+ { timeout: 5000 }, (err) => { sr = !err; finish(); });
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Open System Preferences pane for a given permission type.
40
+ * @param {"screenRecording"|"accessibility"} type
41
+ */
42
+ export function openPermissionPane(type) {
43
+ const url = PERM_URLS[type];
44
+ if (url && process.platform === "darwin") exec(`open "${url}"`, () => {});
45
+ }
@@ -5,6 +5,7 @@ import os from "os";
5
5
  const STATE_DIR = path.join(os.homedir(), ".9remote");
6
6
  const STATE_FILE = path.join(STATE_DIR, "state.json");
7
7
  const KEYS_FILE = path.join(STATE_DIR, "keys.json");
8
+ const CMD_FILE = path.join(STATE_DIR, "cmd.json");
8
9
 
9
10
  /**
10
11
  * Ensure state directory exists
@@ -71,6 +72,28 @@ export function loadKey() {
71
72
  }
72
73
  }
73
74
 
75
+ // ==================== IPC CMD ====================
76
+
77
+ /** Write a command for CLI to pick up */
78
+ export function writeCmd(cmd) {
79
+ try {
80
+ ensureDir();
81
+ fs.writeFileSync(CMD_FILE, JSON.stringify({ cmd, ts: Date.now() }));
82
+ } catch {}
83
+ }
84
+
85
+ /** Read and clear pending command (returns null if none) */
86
+ export function readAndClearCmd() {
87
+ try {
88
+ if (!fs.existsSync(CMD_FILE)) return null;
89
+ const data = JSON.parse(fs.readFileSync(CMD_FILE, "utf8"));
90
+ fs.unlinkSync(CMD_FILE);
91
+ return data.cmd;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
74
97
  /**
75
98
  * Save single key to file (overwrites existing)
76
99
  */
@@ -0,0 +1,250 @@
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 showBanner(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
+ 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());
52
+
53
+ 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());
59
+ }
60
+
61
+ console.log(C.orange + "╚" + "═".repeat(inner) + "╝" + C.reset);
62
+ console.log("");
63
+ }
64
+
65
+ // ── Progress ──────────────────────────────────────────────────────────────────
66
+
67
+ 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" },
72
+ ];
73
+
74
+ let _progressLines = 0;
75
+
76
+ export function renderProgress(activeIdx, redraw = false) {
77
+ if (redraw && _progressLines > 0) {
78
+ process.stdout.write(`\x1b[${_progressLines}A\x1b[0J`);
79
+ }
80
+
81
+ const lines = [];
82
+ STEPS.forEach((step, i) => {
83
+ if (i < activeIdx) {
84
+ 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}`);
87
+ } else {
88
+ lines.push(` ${C.dim}○ ${step.label}${C.reset}`);
89
+ }
90
+ });
91
+
92
+ lines.forEach((l) => console.log(l));
93
+ _progressLines = lines.length;
94
+ }
95
+
96
+ export function resetProgress() {
97
+ _progressLines = 0;
98
+ }
99
+
100
+ // ── selectMenu ────────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Interactive arrow-key menu. Clears full screen on each render.
104
+ * Mirrors 9router_cli pattern exactly: emitKeypressEvents → setRawMode → on("keypress") → resume.
105
+ * cleanup: setRawMode(false) → removeListener → pause.
106
+ * Subsequent readline.createInterface calls work because they resume stdin internally.
107
+ *
108
+ * @param {string} title
109
+ * @param {Array<{label: string}>} items
110
+ * @param {number} defaultIndex
111
+ * @param {string} headerContent — pre-built string shown above menu
112
+ * @param {(setRedraw: () => void) => void} onRedrawInit — receive a redraw trigger fn (for SSE updates)
113
+ * @returns {Promise<number>} selected index, -1 on ESC
114
+ */
115
+ export function selectMenu(title, items, defaultIndex = 0, headerContent = "", onRedrawInit = null) {
116
+ return new Promise((resolve) => {
117
+ let selected = defaultIndex;
118
+ let isActive = true;
119
+ const isWin = process.platform === "win32";
120
+
121
+ const renderMenu = () => {
122
+ if (!isActive) return;
123
+ process.stdout.write("\x1b[2J\x1b[H");
124
+ // Support both static string and dynamic getter function
125
+ const header = typeof headerContent === "function" ? headerContent() : headerContent;
126
+ if (header) {
127
+ process.stdout.write(header + "\n");
128
+ }
129
+ process.stdout.write(`${C.dim}${title}${C.reset}\n\n`);
130
+ items.forEach((item, i) => {
131
+ const icon = i === selected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
132
+ if (i === selected) {
133
+ console.log(` \x1b[7m${C.bold}${icon} ${item.label}${C.reset}`);
134
+ } else {
135
+ console.log(` ${icon} ${item.label}`);
136
+ }
137
+ });
138
+ };
139
+
140
+ const cleanup = () => {
141
+ if (!isActive) return;
142
+ isActive = false;
143
+ if (process.stdin.isTTY) {
144
+ try { process.stdin.setRawMode(false); } catch {}
145
+ }
146
+ process.stdin.removeListener("keypress", onKeypress);
147
+ process.stdin.pause();
148
+ };
149
+
150
+ const onKeypress = (str, key) => {
151
+ if (!isActive || !key) return;
152
+ if (key.name === "up") {
153
+ selected = (selected - 1 + items.length) % items.length;
154
+ renderMenu();
155
+ } else if (key.name === "down") {
156
+ selected = (selected + 1) % items.length;
157
+ renderMenu();
158
+ } else if (key.name === "return") {
159
+ cleanup();
160
+ resolve(selected);
161
+ } else if (key.name === "escape") {
162
+ cleanup();
163
+ resolve(-1);
164
+ } else if (key.ctrl && key.name === "c") {
165
+ cleanup();
166
+ process.exit(0);
167
+ }
168
+ };
169
+
170
+ // Exact same order as 9router_cli
171
+ process.stdin.removeAllListeners("keypress");
172
+ readline.emitKeypressEvents(process.stdin);
173
+ if (process.stdin.isTTY) {
174
+ try { process.stdin.setRawMode(true); } catch { resolve(-1); return; }
175
+ }
176
+ process.stdin.on("keypress", onKeypress);
177
+ process.stdin.resume();
178
+ renderMenu();
179
+
180
+ // Allow external code (SSE) to trigger a re-render without disrupting navigation
181
+ if (onRedrawInit) onRedrawInit(renderMenu);
182
+ });
183
+ }
184
+
185
+ // ── confirm ───────────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Yes/no prompt. Uses readline.createInterface which resumes stdin internally.
189
+ * Works after selectMenu.cleanup() which pauses stdin.
190
+ */
191
+ export function confirm(message) {
192
+ return new Promise((resolve) => {
193
+ // Ensure clean state
194
+ if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
195
+ process.stdin.removeAllListeners("keypress");
196
+
197
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
198
+ rl.question(`${message} (y/N): `, (answer) => {
199
+ rl.close();
200
+ resolve(answer.trim().toLowerCase() === "y");
201
+ });
202
+ });
203
+ }
204
+
205
+ // ── SSE client ───────────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Subscribe to server SSE stream. Calls onEvent(type, data) for each event.
209
+ * Returns a cleanup function to close the connection.
210
+ * @param {number} port
211
+ * @param {(type: string, data: object) => void} onEvent
212
+ * @returns {() => void} cleanup
213
+ */
214
+ export function subscribeSSE(port, onEvent) {
215
+ let req = null;
216
+ let closed = false;
217
+
218
+ const connect = () => {
219
+ if (closed) return;
220
+ req = http.get(`http://localhost:${port}/api/ui/events`, (res) => {
221
+ let buf = "";
222
+ res.on("data", (chunk) => {
223
+ buf += chunk.toString();
224
+ const lines = buf.split("\n");
225
+ buf = lines.pop(); // keep incomplete line
226
+ let eventData = "";
227
+ for (const line of lines) {
228
+ if (line.startsWith("data: ")) {
229
+ eventData = line.slice(6);
230
+ } else if (line === "" && eventData) {
231
+ try {
232
+ const parsed = JSON.parse(eventData);
233
+ onEvent(parsed.type, parsed);
234
+ } catch {}
235
+ eventData = "";
236
+ }
237
+ }
238
+ });
239
+ res.on("end", () => {
240
+ if (!closed) setTimeout(connect, 2000); // reconnect
241
+ });
242
+ });
243
+ req.on("error", () => {
244
+ if (!closed) setTimeout(connect, 2000); // reconnect on error
245
+ });
246
+ };
247
+
248
+ connect();
249
+ return () => { closed = true; req?.destroy(); };
250
+ }
@@ -86,6 +86,28 @@ export async function checkForUpdates() {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * Check if a newer version exists — returns { current, latest } or null.
91
+ * Silent fail, no auto-update, no spinner.
92
+ */
93
+ export async function checkLatestVersion() {
94
+ try {
95
+ const currentVersion = getCurrentVersion();
96
+ if (!currentVersion) return null;
97
+ const response = await fetch(NPM_REGISTRY_URL, {
98
+ signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT)
99
+ });
100
+ if (!response.ok) return null;
101
+ const { version: latestVersion } = await response.json();
102
+ if (latestVersion && isNewerVersion(currentVersion, latestVersion)) {
103
+ return { current: currentVersion, latest: latestVersion };
104
+ }
105
+ return null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
89
111
  /**
90
112
  * Check and auto-update if new version available
91
113
  * Returns true if update started (process will exit), false otherwise