openclaw-navigator 1.0.0
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.mjs +306 -0
- package/package.json +23 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* openclaw-navigator
|
|
5
|
+
*
|
|
6
|
+
* Standalone CLI to pair the Navigator macOS browser with an OpenClaw gateway.
|
|
7
|
+
* Zero dependencies — pure Node.js.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx openclaw-navigator
|
|
11
|
+
* npx openclaw-navigator --port 18789
|
|
12
|
+
* npx openclaw-navigator --url http://192.168.1.50:18789
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { execSync, spawn } from "node:child_process";
|
|
17
|
+
import { networkInterfaces, hostname, userInfo } from "node:os";
|
|
18
|
+
|
|
19
|
+
// ── Colors (ANSI) ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const RESET = "\x1b[0m";
|
|
22
|
+
const BOLD = "\x1b[1m";
|
|
23
|
+
const DIM = "\x1b[2m";
|
|
24
|
+
const GREEN = "\x1b[32m";
|
|
25
|
+
const YELLOW = "\x1b[33m";
|
|
26
|
+
const RED = "\x1b[31m";
|
|
27
|
+
const CYAN = "\x1b[36m";
|
|
28
|
+
const MAGENTA = "\x1b[35m";
|
|
29
|
+
|
|
30
|
+
const ok = (msg) => console.log(`${GREEN}✓${RESET} ${msg}`);
|
|
31
|
+
const warn = (msg) => console.log(`${YELLOW}⚠${RESET} ${msg}`);
|
|
32
|
+
const fail = (msg) => console.error(`${RED}✗${RESET} ${msg}`);
|
|
33
|
+
const info = (msg) => console.log(`${DIM}${msg}${RESET}`);
|
|
34
|
+
const heading = (msg) => console.log(`\n${BOLD}${MAGENTA}${msg}${RESET}`);
|
|
35
|
+
|
|
36
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function getLocalIP() {
|
|
39
|
+
const ifaces = networkInterfaces();
|
|
40
|
+
for (const entries of Object.values(ifaces)) {
|
|
41
|
+
if (!entries) continue;
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (e.family === "IPv4" && !e.internal) return e.address;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getTailscaleIP() {
|
|
50
|
+
const ifaces = networkInterfaces();
|
|
51
|
+
for (const entries of Object.values(ifaces)) {
|
|
52
|
+
if (!entries) continue;
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
if (e.family !== "IPv4" || e.internal) continue;
|
|
55
|
+
const [a, b] = e.address.split(".").map(Number);
|
|
56
|
+
if (a === 100 && b >= 64 && b <= 127) return e.address;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commandExists(cmd) {
|
|
63
|
+
try {
|
|
64
|
+
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function ask(question, options) {
|
|
72
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
if (options) {
|
|
75
|
+
console.log(`\n${BOLD}${question}${RESET}`);
|
|
76
|
+
options.forEach((opt, i) => {
|
|
77
|
+
console.log(` ${CYAN}${i + 1}${RESET}) ${opt.label}${opt.hint ? ` ${DIM}${opt.hint}${RESET}` : ""}`);
|
|
78
|
+
});
|
|
79
|
+
rl.question(`\n${DIM}Enter choice [1-${options.length}]:${RESET} `, (answer) => {
|
|
80
|
+
rl.close();
|
|
81
|
+
const idx = parseInt(answer, 10) - 1;
|
|
82
|
+
resolve(options[idx]?.value ?? options[0]?.value);
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
rl.question(`${question} `, (answer) => {
|
|
86
|
+
rl.close();
|
|
87
|
+
resolve(answer.trim());
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function checkGateway(baseURL) {
|
|
94
|
+
try {
|
|
95
|
+
const resp = await fetch(`${baseURL}/navigator/status`, {
|
|
96
|
+
signal: AbortSignal.timeout(5000),
|
|
97
|
+
});
|
|
98
|
+
if (!resp.ok) return false;
|
|
99
|
+
const json = await resp.json();
|
|
100
|
+
return json.ok === true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function generateToken(baseURL, displayName) {
|
|
107
|
+
try {
|
|
108
|
+
const resp = await fetch(`${baseURL}/navigator/pair`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify({ displayName }),
|
|
112
|
+
signal: AbortSignal.timeout(5000),
|
|
113
|
+
});
|
|
114
|
+
if (!resp.ok) return null;
|
|
115
|
+
return await resp.json();
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function startCloudflaredTunnel(port) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
|
|
124
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let done = false;
|
|
128
|
+
const timeout = setTimeout(() => {
|
|
129
|
+
if (!done) { done = true; child.kill(); resolve(null); }
|
|
130
|
+
}, 30_000);
|
|
131
|
+
|
|
132
|
+
const onData = (data) => {
|
|
133
|
+
const match = data.toString().match(/https?:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
134
|
+
if (match && !done) {
|
|
135
|
+
done = true;
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
child.unref();
|
|
138
|
+
resolve(match[0]);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
child.stdout?.on("data", onData);
|
|
143
|
+
child.stderr?.on("data", onData);
|
|
144
|
+
child.on("error", () => { if (!done) { done = true; clearTimeout(timeout); resolve(null); } });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Connection Box ─────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
151
|
+
const w = 58;
|
|
152
|
+
const bar = "═".repeat(w);
|
|
153
|
+
const pad = (s) => s + " ".repeat(Math.max(0, w - s.length));
|
|
154
|
+
|
|
155
|
+
console.log("");
|
|
156
|
+
console.log(`${MAGENTA}╔${bar}╗${RESET}`);
|
|
157
|
+
console.log(`${MAGENTA}║${RESET} ${BOLD}🧭 Navigator Connection Info${RESET}${" ".repeat(w - 30)}${MAGENTA}║${RESET}`);
|
|
158
|
+
console.log(`${MAGENTA}║${RESET} ${DIM}Method: ${method}${RESET}${" ".repeat(Math.max(0, w - 9 - method.length))}${MAGENTA}║${RESET}`);
|
|
159
|
+
console.log(`${MAGENTA}╠${bar}╣${RESET}`);
|
|
160
|
+
console.log(`${MAGENTA}║${RESET}${" ".repeat(w)}${MAGENTA}║${RESET}`);
|
|
161
|
+
console.log(`${MAGENTA}║${RESET} ${pad(`URL: ${gatewayURL}`)}${MAGENTA}║${RESET}`);
|
|
162
|
+
console.log(`${MAGENTA}║${RESET} ${pad(`Token: ${token}`)}${MAGENTA}║${RESET}`);
|
|
163
|
+
console.log(`${MAGENTA}║${RESET}${" ".repeat(w)}${MAGENTA}║${RESET}`);
|
|
164
|
+
console.log(`${MAGENTA}╠${bar}╣${RESET}`);
|
|
165
|
+
console.log(`${MAGENTA}║${RESET} ${DIM}Deep link (paste in Navigator address bar):${RESET}${" ".repeat(Math.max(0, w - 44))}${MAGENTA}║${RESET}`);
|
|
166
|
+
console.log(`${MAGENTA}╚${bar}╝${RESET}`);
|
|
167
|
+
console.log("");
|
|
168
|
+
console.log(` ${CYAN}${deepLink}${RESET}`);
|
|
169
|
+
console.log("");
|
|
170
|
+
console.log(`${DIM} Paste URL + Token into Navigator > Settings > OpenClaw${RESET}`);
|
|
171
|
+
console.log(`${DIM} Or paste the deep link into the address bar and press Enter.${RESET}`);
|
|
172
|
+
console.log("");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async function main() {
|
|
178
|
+
// Parse args
|
|
179
|
+
const args = process.argv.slice(2);
|
|
180
|
+
let port = 18789;
|
|
181
|
+
let explicitURL = null;
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < args.length; i++) {
|
|
184
|
+
if (args[i] === "--port" && args[i + 1]) port = parseInt(args[i + 1], 10);
|
|
185
|
+
if (args[i] === "--url" && args[i + 1]) explicitURL = args[i + 1];
|
|
186
|
+
if (args[i] === "--help" || args[i] === "-h") {
|
|
187
|
+
console.log(`
|
|
188
|
+
${BOLD}openclaw-navigator${RESET} — Connect Navigator browser to OpenClaw
|
|
189
|
+
|
|
190
|
+
${BOLD}Usage:${RESET}
|
|
191
|
+
npx openclaw-navigator
|
|
192
|
+
npx openclaw-navigator --port 18789
|
|
193
|
+
npx openclaw-navigator --url http://192.168.1.50:18789
|
|
194
|
+
|
|
195
|
+
${BOLD}Options:${RESET}
|
|
196
|
+
--port <port> Gateway port (default: 18789)
|
|
197
|
+
--url <url> Explicit gateway HTTP base URL
|
|
198
|
+
--help Show this help
|
|
199
|
+
`);
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const baseURL = explicitURL ?? `http://127.0.0.1:${port}`;
|
|
205
|
+
|
|
206
|
+
heading("🧭 Navigator Setup");
|
|
207
|
+
info("Connect the Navigator browser to this OpenClaw gateway\n");
|
|
208
|
+
|
|
209
|
+
// ── Step 1: Check gateway ───────────────────────────────────────────────
|
|
210
|
+
process.stdout.write(`${DIM}Checking gateway at ${baseURL}...${RESET}`);
|
|
211
|
+
const running = await checkGateway(baseURL);
|
|
212
|
+
|
|
213
|
+
if (!running) {
|
|
214
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
215
|
+
fail(`Gateway not reachable at ${baseURL}/navigator/status`);
|
|
216
|
+
console.log("");
|
|
217
|
+
console.log(" Make sure the gateway is running:");
|
|
218
|
+
console.log(` ${CYAN}openclaw gateway --allow-unconfigured${RESET}`);
|
|
219
|
+
console.log("");
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
224
|
+
ok(`Gateway running at ${baseURL}`);
|
|
225
|
+
|
|
226
|
+
// ── Step 2: Choose connection method ────────────────────────────────────
|
|
227
|
+
const hasCloudflared = commandExists("cloudflared");
|
|
228
|
+
const tailscaleIP = getTailscaleIP();
|
|
229
|
+
const localIP = getLocalIP();
|
|
230
|
+
|
|
231
|
+
const methods = [];
|
|
232
|
+
if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel (recommended)", hint: "works anywhere" });
|
|
233
|
+
if (tailscaleIP) methods.push({ value: "tailscale", label: "Tailscale", hint: tailscaleIP });
|
|
234
|
+
methods.push({ value: "ssh", label: "SSH Tunnel", hint: "run SSH on your Mac" });
|
|
235
|
+
if (localIP) methods.push({ value: "lan", label: "Direct LAN", hint: localIP });
|
|
236
|
+
|
|
237
|
+
const method = await ask("How will Navigator connect to this machine?", methods);
|
|
238
|
+
|
|
239
|
+
// ── Step 3: Resolve gateway URL ─────────────────────────────────────────
|
|
240
|
+
let gatewayURL;
|
|
241
|
+
|
|
242
|
+
switch (method) {
|
|
243
|
+
case "cloudflare": {
|
|
244
|
+
process.stdout.write(`\n${DIM}Starting Cloudflare tunnel...${RESET}`);
|
|
245
|
+
const tunnelURL = await startCloudflaredTunnel(port);
|
|
246
|
+
if (!tunnelURL) {
|
|
247
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
248
|
+
fail("Failed to start tunnel. Install: brew install cloudflare/cloudflare/cloudflared");
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
252
|
+
ok(`Tunnel active: ${tunnelURL}`);
|
|
253
|
+
gatewayURL = tunnelURL.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "tailscale": {
|
|
257
|
+
gatewayURL = `ws://${tailscaleIP}:${port}`;
|
|
258
|
+
ok(`Using Tailscale: ${tailscaleIP}:${port}`);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case "ssh": {
|
|
262
|
+
gatewayURL = `ws://127.0.0.1:${port}`;
|
|
263
|
+
console.log("");
|
|
264
|
+
console.log(`${BOLD}Run this on your Mac:${RESET}`);
|
|
265
|
+
console.log(` ${CYAN}ssh -L ${port}:127.0.0.1:${port} ${userInfo().username}@${hostname()}${RESET}`);
|
|
266
|
+
console.log(`${DIM}Keep SSH open while using Navigator.${RESET}`);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "lan": {
|
|
270
|
+
gatewayURL = `ws://${localIP}:${port}`;
|
|
271
|
+
ok(`Using LAN: ${localIP}:${port}`);
|
|
272
|
+
warn("Make sure gateway is bound to LAN (--bind lan) and both machines are on the same network.");
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Step 4: Generate pairing token ──────────────────────────────────────
|
|
278
|
+
const displayName = hostname().replace(/\.local$/, "");
|
|
279
|
+
process.stdout.write(`\n${DIM}Generating pairing token...${RESET}`);
|
|
280
|
+
const pairing = await generateToken(baseURL, displayName);
|
|
281
|
+
|
|
282
|
+
if (!pairing?.token) {
|
|
283
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
284
|
+
fail("Failed to generate pairing token from /navigator/pair");
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
289
|
+
ok("Pairing token generated");
|
|
290
|
+
|
|
291
|
+
// ── Step 5: Show result ─────────────────────────────────────────────────
|
|
292
|
+
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${pairing.token}&name=${encodeURIComponent(displayName)}`;
|
|
293
|
+
|
|
294
|
+
showConnectionBox(gatewayURL, pairing.token, deepLink, method);
|
|
295
|
+
|
|
296
|
+
// Keep tunnel alive if cloudflare
|
|
297
|
+
if (method === "cloudflare") {
|
|
298
|
+
console.log(`${YELLOW}Tunnel is running. Press Ctrl+C to stop.${RESET}\n`);
|
|
299
|
+
await new Promise(() => {}); // wait forever
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
main().catch((err) => {
|
|
304
|
+
fail(err.message || String(err));
|
|
305
|
+
process.exit(1);
|
|
306
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-navigator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Connect the Navigator browser to your OpenClaw gateway",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"openclaw-navigator": "cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.mjs"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.0.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"openclaw",
|
|
18
|
+
"navigator",
|
|
19
|
+
"browser",
|
|
20
|
+
"gateway",
|
|
21
|
+
"setup"
|
|
22
|
+
]
|
|
23
|
+
}
|