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.
- package/cli/index.js +379 -198
- package/cli/utils/apiKey.js +13 -8
- package/cli/utils/permissions.js +45 -0
- package/cli/utils/state.js +23 -0
- package/cli/utils/tui.js +251 -0
- package/cli/utils/updateChecker.js +22 -0
- package/dist/cli.cjs +44 -95
- package/dist/server.cjs +48 -45
- package/dist/ui/assets/index-CMD-4YxV.js +8 -0
- package/dist/ui/assets/index-D4GJ1wNn.css +1 -0
- package/dist/ui/index.html +2 -2
- package/index.js +200 -8
- package/package.json +1 -1
- package/dist/ui/assets/index-BIuRs677.js +0 -8
- package/dist/ui/assets/index-DhKgENYK.css +0 -1
package/cli/utils/apiKey.js
CHANGED
|
@@ -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
|
+
* 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 <
|
|
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 (
|
|
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,
|
|
25
|
+
.slice(0, 6);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Generate API key with machineId embedded
|
|
30
|
-
* Format: sk-{
|
|
31
|
-
* @param {string} machineId -
|
|
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(
|
|
37
|
-
const key = `sk-${
|
|
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
|
+
}
|
package/cli/utils/state.js
CHANGED
|
@@ -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
|
*/
|
package/cli/utils/tui.js
ADDED
|
@@ -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
|