9remote 0.1.53 → 0.1.55

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
@@ -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,251 @@
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, onCtrlC = 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
+ if (onCtrlC) onCtrlC();
167
+ process.exit(0);
168
+ }
169
+ };
170
+
171
+ // Exact same order as 9router_cli
172
+ process.stdin.removeAllListeners("keypress");
173
+ readline.emitKeypressEvents(process.stdin);
174
+ if (process.stdin.isTTY) {
175
+ try { process.stdin.setRawMode(true); } catch { resolve(-1); return; }
176
+ }
177
+ process.stdin.on("keypress", onKeypress);
178
+ process.stdin.resume();
179
+ renderMenu();
180
+
181
+ // Allow external code (SSE) to trigger a re-render without disrupting navigation
182
+ if (onRedrawInit) onRedrawInit(renderMenu);
183
+ });
184
+ }
185
+
186
+ // ── confirm ───────────────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Yes/no prompt. Uses readline.createInterface which resumes stdin internally.
190
+ * Works after selectMenu.cleanup() which pauses stdin.
191
+ */
192
+ export function confirm(message) {
193
+ return new Promise((resolve) => {
194
+ // Ensure clean state
195
+ if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
196
+ process.stdin.removeAllListeners("keypress");
197
+
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
+ });
203
+ });
204
+ }
205
+
206
+ // ── SSE client ───────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Subscribe to server SSE stream. Calls onEvent(type, data) for each event.
210
+ * Returns a cleanup function to close the connection.
211
+ * @param {number} port
212
+ * @param {(type: string, data: object) => void} onEvent
213
+ * @returns {() => void} cleanup
214
+ */
215
+ export function subscribeSSE(port, onEvent) {
216
+ let req = null;
217
+ let closed = false;
218
+
219
+ const connect = () => {
220
+ if (closed) return;
221
+ req = http.get(`http://localhost:${port}/api/ui/events`, (res) => {
222
+ let buf = "";
223
+ res.on("data", (chunk) => {
224
+ buf += chunk.toString();
225
+ const lines = buf.split("\n");
226
+ buf = lines.pop(); // keep incomplete line
227
+ let eventData = "";
228
+ for (const line of lines) {
229
+ if (line.startsWith("data: ")) {
230
+ eventData = line.slice(6);
231
+ } else if (line === "" && eventData) {
232
+ try {
233
+ const parsed = JSON.parse(eventData);
234
+ onEvent(parsed.type, parsed);
235
+ } catch {}
236
+ eventData = "";
237
+ }
238
+ }
239
+ });
240
+ res.on("end", () => {
241
+ if (!closed) setTimeout(connect, 2000); // reconnect
242
+ });
243
+ });
244
+ req.on("error", () => {
245
+ if (!closed) setTimeout(connect, 2000); // reconnect on error
246
+ });
247
+ };
248
+
249
+ connect();
250
+ return () => { closed = true; req?.destroy(); };
251
+ }
@@ -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