openclaw-navigator 2.1.0 → 4.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.
Files changed (2) hide show
  1. package/cli.mjs +294 -280
  2. package/package.json +15 -10
package/cli.mjs CHANGED
@@ -1,24 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * openclaw-navigator
4
+ * openclaw-navigator v4.0.0
5
5
  *
6
- * Self-contained Navigator bridge server.
7
- * Starts an HTTP server with all Navigator endpoints, sets up a tunnel,
8
- * generates pairing info, and keeps running as the bridge.
9
- *
10
- * Zero dependencies — pure Node.js.
6
+ * One-command bridge + tunnel for the Navigator browser.
7
+ * Starts a local bridge, creates a Cloudflare tunnel automatically,
8
+ * and gives you a 6-digit pairing code. Works on any OS.
11
9
  *
12
10
  * Usage:
13
- * npx openclaw-navigator
14
- * npx openclaw-navigator --port 18790
11
+ * npx openclaw-navigator Auto-tunnel (default)
12
+ * npx openclaw-navigator --no-tunnel Local only (SSH/LAN mode)
13
+ * npx openclaw-navigator --port 18790 Custom port
15
14
  */
16
15
 
16
+ import { spawn } from "node:child_process";
17
+ import { randomUUID } from "node:crypto";
18
+ import { existsSync } from "node:fs";
17
19
  import { createServer } from "node:http";
18
- import { createInterface } from "node:readline";
19
- import { execSync, spawn } from "node:child_process";
20
20
  import { networkInterfaces, hostname, userInfo } from "node:os";
21
- import { randomUUID } from "node:crypto";
21
+ // readline reserved for future interactive mode
22
22
 
23
23
  // ── Colors (ANSI) ──────────────────────────────────────────────────────────
24
24
 
@@ -53,14 +53,26 @@ const recentEvents = [];
53
53
  const MAX_EVENTS = 200;
54
54
  const validTokens = new Set();
55
55
 
56
- // ── Helpers ────────────────────────────────────────────────────────────────
56
+ // Pairing code state
57
+ let pairingCode = null;
58
+ let pairingData = null;
59
+
60
+ function generatePairingCode() {
61
+ return String(Math.floor(100000 + Math.random() * 900000));
62
+ }
63
+
64
+ // ── Network helpers ────────────────────────────────────────────────────────
57
65
 
58
66
  function getLocalIP() {
59
67
  const ifaces = networkInterfaces();
60
68
  for (const entries of Object.values(ifaces)) {
61
- if (!entries) continue;
69
+ if (!entries) {
70
+ continue;
71
+ }
62
72
  for (const e of entries) {
63
- if (e.family === "IPv4" && !e.internal) return e.address;
73
+ if (e.family === "IPv4" && !e.internal) {
74
+ return e.address;
75
+ }
64
76
  }
65
77
  }
66
78
  return null;
@@ -69,85 +81,64 @@ function getLocalIP() {
69
81
  function getTailscaleIP() {
70
82
  const ifaces = networkInterfaces();
71
83
  for (const entries of Object.values(ifaces)) {
72
- if (!entries) continue;
84
+ if (!entries) {
85
+ continue;
86
+ }
73
87
  for (const e of entries) {
74
- if (e.family !== "IPv4" || e.internal) continue;
88
+ if (e.family !== "IPv4" || e.internal) {
89
+ continue;
90
+ }
75
91
  const [a, b] = e.address.split(".").map(Number);
76
- if (a === 100 && b >= 64 && b <= 127) return e.address;
92
+ if (a === 100 && b >= 64 && b <= 127) {
93
+ return e.address;
94
+ }
77
95
  }
78
96
  }
79
97
  return null;
80
98
  }
81
99
 
82
- function getTailscaleDNS() {
83
- try {
84
- const raw = execSync("tailscale status --json", { encoding: "utf8", timeout: 5000 });
85
- const status = JSON.parse(raw);
86
- const dns = status?.Self?.DNSName;
87
- if (dns) return dns.replace(/\.$/, "");
88
- } catch { /* ignore */ }
89
- return null;
90
- }
100
+ // ── Cloudflared auto-install + tunnel ─────────────────────────────────────
91
101
 
92
- function setupTailscaleServe(localPort, httpsPort = 8443) {
93
- try {
94
- // Set up tailscale serve to proxy HTTPS our local HTTP server
95
- execSync(`tailscale serve --bg --https=${httpsPort} http://localhost:${localPort}`, {
96
- stdio: "ignore",
97
- timeout: 10_000,
98
- });
99
- return true;
100
- } catch {
101
- return false;
102
- }
103
- }
104
-
105
- function teardownTailscaleServe(httpsPort = 8443) {
102
+ /**
103
+ * Ensure the cloudflared binary is available.
104
+ * Uses the `cloudflared` npm package which auto-downloads the right binary
105
+ * for the current platform on first use.
106
+ */
107
+ async function ensureCloudflared() {
106
108
  try {
107
- execSync(`tailscale serve --https=${httpsPort} off`, { stdio: "ignore", timeout: 5000 });
108
- } catch { /* ignore */ }
109
- }
109
+ const cf = await import("cloudflared");
110
+ const binPath = cf.bin;
111
+
112
+ if (!existsSync(binPath)) {
113
+ process.stdout.write(` ${DIM}Downloading tunnel tool (one-time)...${RESET}`);
114
+ await cf.install(binPath);
115
+ process.stdout.write(`\r${" ".repeat(60)}\r`);
116
+ ok("Tunnel tool installed");
117
+ }
110
118
 
111
- function commandExists(cmd) {
112
- try {
113
- execSync(`which ${cmd}`, { stdio: "ignore" });
114
- return true;
119
+ return binPath;
115
120
  } catch {
116
- return false;
121
+ return null;
117
122
  }
118
123
  }
119
124
 
120
- async function ask(question, options) {
121
- const rl = createInterface({ input: process.stdin, output: process.stdout });
122
- return new Promise((resolve) => {
123
- if (options) {
124
- console.log(`\n${BOLD}${question}${RESET}`);
125
- options.forEach((opt, i) => {
126
- console.log(` ${CYAN}${i + 1}${RESET}) ${opt.label}${opt.hint ? ` ${DIM}${opt.hint}${RESET}` : ""}`);
127
- });
128
- rl.question(`\n${DIM}Enter choice [1-${options.length}]:${RESET} `, (answer) => {
129
- rl.close();
130
- const idx = parseInt(answer, 10) - 1;
131
- resolve(options[idx]?.value ?? options[0]?.value);
132
- });
133
- } else {
134
- rl.question(`${question} `, (answer) => {
135
- rl.close();
136
- resolve(answer.trim());
137
- });
138
- }
139
- });
140
- }
141
-
142
- function startCloudflaredTunnel(port) {
125
+ /**
126
+ * Start a Cloudflare Quick Tunnel pointing at our local bridge.
127
+ * Returns the public https:// URL, or null on failure.
128
+ */
129
+ function startTunnel(cloudflaredBin, port) {
143
130
  return new Promise((resolve) => {
144
- const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
131
+ const child = spawn(cloudflaredBin, ["tunnel", "--url", `http://localhost:${port}`], {
145
132
  stdio: ["ignore", "pipe", "pipe"],
146
133
  });
147
134
 
148
135
  let done = false;
149
136
  const timeout = setTimeout(() => {
150
- if (!done) { done = true; child.kill(); resolve(null); }
137
+ if (!done) {
138
+ done = true;
139
+ child.kill();
140
+ resolve(null);
141
+ }
151
142
  }, 30_000);
152
143
 
153
144
  const onData = (data) => {
@@ -155,14 +146,19 @@ function startCloudflaredTunnel(port) {
155
146
  if (match && !done) {
156
147
  done = true;
157
148
  clearTimeout(timeout);
158
- child.unref();
159
- resolve(match[0]);
149
+ resolve({ url: match[0], process: child });
160
150
  }
161
151
  };
162
152
 
163
153
  child.stdout?.on("data", onData);
164
154
  child.stderr?.on("data", onData);
165
- child.on("error", () => { if (!done) { done = true; clearTimeout(timeout); resolve(null); } });
155
+ child.on("error", () => {
156
+ if (!done) {
157
+ done = true;
158
+ clearTimeout(timeout);
159
+ resolve(null);
160
+ }
161
+ });
166
162
  });
167
163
  }
168
164
 
@@ -173,7 +169,9 @@ async function readBody(req, maxBytes = 1024 * 1024) {
173
169
  let size = 0;
174
170
  for await (const chunk of req) {
175
171
  size += chunk.length;
176
- if (size > maxBytes) throw new Error("payload too large");
172
+ if (size > maxBytes) {
173
+ throw new Error("payload too large");
174
+ }
177
175
  chunks.push(chunk);
178
176
  }
179
177
  return Buffer.concat(chunks).toString("utf8");
@@ -201,7 +199,7 @@ function handleRequest(req, res) {
201
199
  return;
202
200
  }
203
201
 
204
- const url = new URL(req.url ?? "/", `http://localhost`);
202
+ const url = new URL(req.url ?? "/", "http://localhost");
205
203
  const path = url.pathname.replace(/\/+$/, "");
206
204
 
207
205
  // ── GET /navigator/status ──
@@ -220,7 +218,6 @@ function handleRequest(req, res) {
220
218
 
221
219
  // ── GET /navigator/commands ──
222
220
  if (req.method === "GET" && path === "/navigator/commands") {
223
- // Mark as connected on first poll
224
221
  if (!bridgeState.connected) {
225
222
  bridgeState.connected = true;
226
223
  bridgeState.connectedAt = Date.now();
@@ -228,7 +225,6 @@ function handleRequest(req, res) {
228
225
  }
229
226
  bridgeState.lastHeartbeat = Date.now();
230
227
 
231
- // Drain pending commands
232
228
  const commands = [...pendingCommands];
233
229
  pendingCommands.length = 0;
234
230
  sendJSON(res, 200, { ok: true, commands });
@@ -237,93 +233,118 @@ function handleRequest(req, res) {
237
233
 
238
234
  // ── POST /navigator/events ──
239
235
  if (req.method === "POST" && path === "/navigator/events") {
240
- readBody(req).then((bodyStr) => {
241
- try {
242
- const body = JSON.parse(bodyStr);
243
- const event = {
244
- type: body.type ?? "unknown",
245
- url: body.url,
246
- title: body.title,
247
- content: body.content,
248
- tabId: body.tabId,
249
- timestamp: body.timestamp ?? Date.now(),
250
- data: body.data,
251
- };
252
- recentEvents.push(event);
253
- if (recentEvents.length > MAX_EVENTS) recentEvents.shift();
254
-
255
- // Update state from heartbeats
256
- if (body.type === "heartbeat") {
257
- bridgeState.lastHeartbeat = Date.now();
258
- bridgeState.activeTabCount = body.data?.tabCount ?? bridgeState.activeTabCount;
259
- bridgeState.currentURL = body.url ?? bridgeState.currentURL;
260
- }
261
- if (body.type === "page.navigated") {
262
- bridgeState.currentURL = body.url;
263
- console.log(` ${DIM}📄 ${body.title || body.url}${RESET}`);
236
+ readBody(req)
237
+ .then((bodyStr) => {
238
+ try {
239
+ const body = JSON.parse(bodyStr);
240
+ const event = {
241
+ type: body.type ?? "unknown",
242
+ url: body.url,
243
+ title: body.title,
244
+ content: body.content,
245
+ tabId: body.tabId,
246
+ timestamp: body.timestamp ?? Date.now(),
247
+ data: body.data,
248
+ };
249
+ recentEvents.push(event);
250
+ if (recentEvents.length > MAX_EVENTS) {
251
+ recentEvents.shift();
252
+ }
253
+
254
+ if (body.type === "heartbeat") {
255
+ bridgeState.lastHeartbeat = Date.now();
256
+ bridgeState.activeTabCount = body.data?.tabCount ?? bridgeState.activeTabCount;
257
+ bridgeState.currentURL = body.url ?? bridgeState.currentURL;
258
+ }
259
+ if (body.type === "page.navigated") {
260
+ bridgeState.currentURL = body.url;
261
+ console.log(` ${DIM}📄 ${body.title || body.url}${RESET}`);
262
+ }
263
+
264
+ sendJSON(res, 200, { ok: true, received: event.type });
265
+ } catch {
266
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
264
267
  }
265
-
266
- sendJSON(res, 200, { ok: true, received: event.type });
267
- } catch {
268
- sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
269
- }
270
- }).catch(() => {
271
- sendJSON(res, 400, { ok: false, error: "Bad request" });
272
- });
268
+ })
269
+ .catch(() => {
270
+ sendJSON(res, 400, { ok: false, error: "Bad request" });
271
+ });
273
272
  return;
274
273
  }
275
274
 
276
275
  // ── POST /navigator/pair ──
277
276
  if (req.method === "POST" && path === "/navigator/pair") {
278
- readBody(req).then((bodyStr) => {
279
- try {
280
- const body = JSON.parse(bodyStr);
281
- const displayName = body.displayName ?? hostname();
282
- const token = randomUUID().replace(/-/g, "");
283
- validTokens.add(token);
284
-
285
- sendJSON(res, 200, {
286
- ok: true,
287
- token,
288
- displayName,
289
- expiresIn: "24 hours",
290
- });
291
- } catch {
292
- sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
293
- }
294
- }).catch(() => {
295
- sendJSON(res, 400, { ok: false, error: "Bad request" });
296
- });
277
+ readBody(req)
278
+ .then((bodyStr) => {
279
+ try {
280
+ const body = JSON.parse(bodyStr);
281
+ const displayName = body.displayName ?? hostname();
282
+ const token = randomUUID().replace(/-/g, "");
283
+ validTokens.add(token);
284
+
285
+ sendJSON(res, 200, {
286
+ ok: true,
287
+ token,
288
+ displayName,
289
+ expiresIn: "24 hours",
290
+ });
291
+ } catch {
292
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
293
+ }
294
+ })
295
+ .catch(() => {
296
+ sendJSON(res, 400, { ok: false, error: "Bad request" });
297
+ });
297
298
  return;
298
299
  }
299
300
 
300
- // ── POST /navigator/command (push command to Navigator) ──
301
+ // ── POST /navigator/command ──
301
302
  if (req.method === "POST" && path === "/navigator/command") {
302
- readBody(req).then((bodyStr) => {
303
- try {
304
- const body = JSON.parse(bodyStr);
305
- const command = body.command ?? body.action;
306
- const payload = body.payload ?? {};
307
-
308
- if (!command || typeof command !== "string") {
309
- sendJSON(res, 400, { ok: false, error: "Missing 'command' field" });
310
- return;
303
+ readBody(req)
304
+ .then((bodyStr) => {
305
+ try {
306
+ const body = JSON.parse(bodyStr);
307
+ const command = body.command ?? body.action;
308
+ const payload = body.payload ?? {};
309
+
310
+ if (!command || typeof command !== "string") {
311
+ sendJSON(res, 400, { ok: false, error: "Missing 'command' field" });
312
+ return;
313
+ }
314
+
315
+ const id = randomUUID();
316
+ pendingCommands.push({ id, command, payload, createdAt: Date.now() });
317
+ console.log(` ${CYAN}⌘${RESET} Command queued: ${command}`);
318
+ sendJSON(res, 200, { ok: true, commandId: id, command });
319
+ } catch {
320
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
311
321
  }
322
+ })
323
+ .catch(() => {
324
+ sendJSON(res, 400, { ok: false, error: "Bad request" });
325
+ });
326
+ return;
327
+ }
312
328
 
313
- const id = randomUUID();
314
- pendingCommands.push({ id, command, payload, createdAt: Date.now() });
315
- console.log(` ${CYAN}⌘${RESET} Command queued: ${command}`);
316
- sendJSON(res, 200, { ok: true, commandId: id, command });
317
- } catch {
318
- sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
319
- }
320
- }).catch(() => {
321
- sendJSON(res, 400, { ok: false, error: "Bad request" });
329
+ // ── GET /navigator/resolve-code ──
330
+ // Navigator sends the 6-digit code; bridge returns URL + token for auto-connect
331
+ if (req.method === "GET" && path === "/navigator/resolve-code") {
332
+ const code = url.searchParams.get("code")?.trim();
333
+ if (!code || !pairingCode || code !== pairingCode) {
334
+ sendJSON(res, 403, { ok: false, error: "Invalid pairing code" });
335
+ return;
336
+ }
337
+ // Code is valid return connection details
338
+ sendJSON(res, 200, {
339
+ ok: true,
340
+ url: pairingData.url,
341
+ token: pairingData.token,
342
+ name: pairingData.name,
322
343
  });
323
344
  return;
324
345
  }
325
346
 
326
- // ── GET /navigator/events (read recent events) ──
347
+ // ── GET /navigator/events ──
327
348
  if (req.method === "GET" && path === "/navigator/events") {
328
349
  const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
329
350
  const events = recentEvents.slice(-Math.min(limit, MAX_EVENTS));
@@ -333,38 +354,34 @@ function handleRequest(req, res) {
333
354
 
334
355
  // ── Health check ──
335
356
  if (req.method === "GET" && (path === "/" || path === "/health")) {
336
- sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "1.1.0" });
357
+ sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "4.0.0" });
337
358
  return;
338
359
  }
339
360
 
340
- // Unknown
341
361
  sendJSON(res, 404, { ok: false, error: "Not found" });
342
362
  }
343
363
 
344
- // ── Connection Box ─────────────────────────────────────────────────────────
345
-
346
- function showConnectionBox(gatewayURL, token, deepLink, method) {
347
- const w = 58;
348
- const bar = "═".repeat(w);
349
- const pad = (s) => s + " ".repeat(Math.max(0, w - s.length));
364
+ // ── Display helpers ────────────────────────────────────────────────────────
350
365
 
366
+ function showPairingCode(code) {
367
+ const spaced = code.split("").join(" ");
351
368
  console.log("");
352
- console.log(`${MAGENTA}╔${bar}╗${RESET}`);
353
- console.log(`${MAGENTA}║${RESET} ${BOLD}🧭 Navigator Connection Info${RESET}${" ".repeat(w - 30)}${MAGENTA}║${RESET}`);
354
- console.log(`${MAGENTA}║${RESET} ${DIM}Method: ${method}${RESET}${" ".repeat(Math.max(0, w - 9 - method.length))}${MAGENTA}║${RESET}`);
355
- console.log(`${MAGENTA}╠${bar}╣${RESET}`);
356
- console.log(`${MAGENTA}║${RESET}${" ".repeat(w)}${MAGENTA}║${RESET}`);
357
- console.log(`${MAGENTA}║${RESET} ${pad(`URL: ${gatewayURL}`)}${MAGENTA}║${RESET}`);
358
- console.log(`${MAGENTA}║${RESET} ${pad(`Token: ${token}`)}${MAGENTA}║${RESET}`);
359
- console.log(`${MAGENTA}║${RESET}${" ".repeat(w)}${MAGENTA}║${RESET}`);
360
- console.log(`${MAGENTA}╠${bar}╣${RESET}`);
361
- console.log(`${MAGENTA}║${RESET} ${DIM}Deep link (paste in Navigator address bar):${RESET}${" ".repeat(Math.max(0, w - 44))}${MAGENTA}║${RESET}`);
362
- console.log(`${MAGENTA}╚${bar}╝${RESET}`);
363
- console.log("");
364
- console.log(` ${CYAN}${deepLink}${RESET}`);
365
- console.log("");
366
- console.log(`${DIM} Paste URL + Token into Navigator > Settings > OpenClaw${RESET}`);
367
- console.log(`${DIM} Or paste the deep link into the address bar and press Enter.${RESET}`);
369
+ console.log(`${MAGENTA}╔══════════════════════════════════════════╗${RESET}`);
370
+ console.log(
371
+ `${MAGENTA}║${RESET} ${BOLD}Your Pairing Code:${RESET} ${MAGENTA}║${RESET}`,
372
+ );
373
+ console.log(`${MAGENTA}║${RESET} ${MAGENTA}║${RESET}`);
374
+ console.log(
375
+ `${MAGENTA}║${RESET} ${BOLD}${GREEN} ${spaced} ${RESET} ${MAGENTA}║${RESET}`,
376
+ );
377
+ console.log(`${MAGENTA}║${RESET} ${MAGENTA}║${RESET}`);
378
+ console.log(
379
+ `${MAGENTA}║${RESET} ${DIM}Enter this in Navigator > Settings > ${RESET}${MAGENTA}║${RESET}`,
380
+ );
381
+ console.log(
382
+ `${MAGENTA}║${RESET} ${DIM}OpenClaw > Quick Connect${RESET} ${MAGENTA}║${RESET}`,
383
+ );
384
+ console.log(`${MAGENTA}╚══════════════════════════════════════════╝${RESET}`);
368
385
  console.log("");
369
386
  }
370
387
 
@@ -373,35 +390,47 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
373
390
  async function main() {
374
391
  const args = process.argv.slice(2);
375
392
  let port = 18790;
393
+ let bindHost = "127.0.0.1";
394
+ let noTunnel = false;
376
395
 
377
396
  for (let i = 0; i < args.length; i++) {
378
- if (args[i] === "--port" && args[i + 1]) port = parseInt(args[i + 1], 10);
397
+ if (args[i] === "--port" && args[i + 1]) {
398
+ port = parseInt(args[i + 1], 10);
399
+ }
400
+ if (args[i] === "--bind" && args[i + 1]) {
401
+ bindHost = args[i + 1];
402
+ }
403
+ if (args[i] === "--no-tunnel") {
404
+ noTunnel = true;
405
+ }
379
406
  if (args[i] === "--help" || args[i] === "-h") {
380
407
  console.log(`
381
- ${BOLD}openclaw-navigator${RESET} — Navigator bridge server
408
+ ${BOLD}openclaw-navigator${RESET} — One-command bridge + tunnel for Navigator
382
409
 
383
410
  ${BOLD}Usage:${RESET}
384
- npx openclaw-navigator Start bridge + tunnel setup
385
- npx openclaw-navigator --port 18790 Use a specific port
411
+ npx openclaw-navigator Auto-tunnel mode (default, works anywhere)
412
+ npx openclaw-navigator --no-tunnel Local-only mode (SSH/LAN)
413
+ npx openclaw-navigator --port 18790 Custom port
386
414
 
387
415
  ${BOLD}Options:${RESET}
388
- --port <port> Bridge server port (default: 18790)
389
- --help Show this help
390
-
391
- ${BOLD}What this does:${RESET}
392
- 1. Starts a local bridge server with Navigator endpoints
393
- 2. Sets up a tunnel so Navigator can reach it
394
- 3. Generates pairing info (URL + Token)
395
- 4. Keeps running as the bridge between Navigator and this machine
396
-
397
- No OpenClaw installation required.
416
+ --port <port> Bridge server port (default: 18790)
417
+ --no-tunnel Skip auto-tunnel, use SSH or LAN instead
418
+ --bind <host> Bind address (default: 127.0.0.1)
419
+ --help Show this help
420
+
421
+ ${BOLD}How it works:${RESET}
422
+ 1. Starts a bridge server on localhost
423
+ 2. Creates a secure Cloudflare tunnel automatically (one-time download)
424
+ 3. Shows a 6-digit pairing code
425
+ 4. Enter the code in Navigator > Settings > OpenClaw > Quick Connect
426
+ 5. That's it. Magic starts happening.
398
427
  `);
399
428
  process.exit(0);
400
429
  }
401
430
  }
402
431
 
403
432
  heading("🧭 Navigator Bridge");
404
- info("Self-contained bridge server for the Navigator browser\n");
433
+ info("One-command bridge + tunnel for the Navigator browser\n");
405
434
 
406
435
  // ── Step 1: Start HTTP server ─────────────────────────────────────────
407
436
  const server = createServer(handleRequest);
@@ -414,117 +443,102 @@ ${BOLD}What this does:${RESET}
414
443
  }
415
444
  reject(err);
416
445
  });
417
- server.listen(port, "0.0.0.0", () => resolve());
446
+ server.listen(port, bindHost, () => resolve());
418
447
  });
419
448
 
420
- ok(`Bridge server running on port ${port}`);
421
- info(` Local: http://127.0.0.1:${port}/navigator/status`);
422
-
423
- // ── Step 2: Choose connection method ──────────────────────────────────
424
- const hasCloudflared = commandExists("cloudflared");
425
- const tailscaleIP = getTailscaleIP();
426
- const localIP = getLocalIP();
427
-
428
- const tailscaleDNS = tailscaleIP ? getTailscaleDNS() : null;
429
-
430
- const methods = [];
431
- if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel (recommended)", hint: "works anywhere, no config" });
432
- if (tailscaleDNS) methods.push({ value: "tailscale-https", label: "Tailscale HTTPS (recommended)", hint: tailscaleDNS });
433
- else if (tailscaleIP) methods.push({ value: "tailscale", label: "Tailscale (raw IP)", hint: tailscaleIP });
434
- methods.push({ value: "ssh", label: "SSH Tunnel", hint: "run SSH on your Mac" });
435
- if (localIP) methods.push({ value: "lan", label: "Direct LAN", hint: localIP });
436
-
437
- const method = await ask("How will Navigator connect to this machine?", methods);
438
-
439
- // ── Step 3: Resolve gateway URL ───────────────────────────────────────
440
- let gatewayURL;
441
- let usedTailscaleServe = false;
442
- const tailscaleHttpsPort = 8443;
443
-
444
- switch (method) {
445
- case "cloudflare": {
446
- process.stdout.write(`\n${DIM}Starting Cloudflare tunnel...${RESET}`);
447
- const tunnelURL = await startCloudflaredTunnel(port);
448
- if (!tunnelURL) {
449
- process.stdout.write("\r" + " ".repeat(60) + "\r");
450
- fail("Failed to start tunnel. Install: brew install cloudflare/cloudflare/cloudflared");
451
- process.exit(1);
452
- }
453
- process.stdout.write("\r" + " ".repeat(60) + "\r");
454
- ok(`Tunnel active: ${tunnelURL}`);
455
- gatewayURL = tunnelURL;
456
- break;
457
- }
458
- case "tailscale-https": {
459
- process.stdout.write(`\n${DIM}Setting up Tailscale HTTPS on port ${tailscaleHttpsPort}...${RESET}`);
460
- const served = setupTailscaleServe(port, tailscaleHttpsPort);
461
- process.stdout.write("\r" + " ".repeat(70) + "\r");
462
- if (served) {
463
- gatewayURL = `https://${tailscaleDNS}:${tailscaleHttpsPort}`;
464
- usedTailscaleServe = true;
465
- ok(`Tailscale HTTPS active: ${gatewayURL}`);
449
+ ok(`Bridge server running on ${bindHost}:${port}`);
450
+
451
+ // ── Step 2: Set up connectivity ───────────────────────────────────────
452
+ const displayName = hostname().replace(/\.local$/, "");
453
+ let gatewayURL = `http://localhost:${port}`;
454
+ let tunnelURL = null;
455
+ let tunnelProcess = null;
456
+
457
+ if (!noTunnel) {
458
+ // ── Auto-tunnel mode (default) ──────────────────────────────────
459
+ process.stdout.write(` ${DIM}Setting up tunnel...${RESET}`);
460
+ const cloudflaredBin = await ensureCloudflared();
461
+
462
+ if (cloudflaredBin) {
463
+ const result = await startTunnel(cloudflaredBin, port);
464
+ process.stdout.write(`\r${" ".repeat(60)}\r`);
465
+
466
+ if (result) {
467
+ tunnelURL = result.url;
468
+ tunnelProcess = result.process;
469
+ gatewayURL = tunnelURL; // Use tunnel URL as the gateway URL
470
+
471
+ ok(`Tunnel active: ${CYAN}${tunnelURL}${RESET}`);
466
472
  } else {
467
- // Fallback to raw IP
468
- warn("tailscale serve failed, falling back to raw IP");
469
- gatewayURL = `http://${tailscaleIP}:${port}`;
470
- ok(`Using Tailscale IP: ${tailscaleIP}:${port}`);
473
+ process.stdout.write(`\r${" ".repeat(60)}\r`);
474
+ warn("Tunnel failed to start. Falling back to local-only mode.");
475
+ warn("Navigator must be on the same machine, or use --no-tunnel + SSH.");
471
476
  }
472
- break;
473
- }
474
- case "tailscale": {
475
- gatewayURL = `http://${tailscaleIP}:${port}`;
476
- ok(`Using Tailscale: ${tailscaleIP}:${port}`);
477
- break;
478
- }
479
- case "ssh": {
480
- gatewayURL = `http://127.0.0.1:${port}`;
481
- console.log("");
482
- console.log(`${BOLD}Run this on your Mac:${RESET}`);
483
- console.log(` ${CYAN}ssh -L ${port}:127.0.0.1:${port} ${userInfo().username}@${hostname()}${RESET}`);
484
- console.log(`${DIM}Keep SSH open while using Navigator.${RESET}`);
485
- break;
477
+ } else {
478
+ process.stdout.write(`\r${" ".repeat(60)}\r`);
479
+ warn("Could not install tunnel tool. Falling back to local-only mode.");
486
480
  }
487
- case "lan": {
488
- gatewayURL = `http://${localIP}:${port}`;
489
- ok(`Using LAN: ${localIP}:${port}`);
490
- warn("Both machines must be on the same network.");
491
- break;
481
+ } else {
482
+ // ── No-tunnel mode (SSH/LAN) ────────────────────────────────────
483
+ const tailscaleIP = getTailscaleIP();
484
+ const localIP = getLocalIP();
485
+ const user = userInfo().username;
486
+ const sshTarget = tailscaleIP ?? localIP ?? "this-machine";
487
+
488
+ console.log("");
489
+ console.log(`${BOLD}${MAGENTA}━━━ SSH Tunnel (for remote Navigator) ━━━${RESET}`);
490
+ console.log(` On your Navigator Mac, run:`);
491
+ console.log(` ${CYAN}${BOLD}ssh -L ${port}:localhost:${port} ${user}@${sshTarget} -N${RESET}`);
492
+ if (tailscaleIP) {
493
+ info(` (${sshTarget} is your Tailscale IP)`);
494
+ } else if (localIP) {
495
+ info(` (${sshTarget} is your LAN IP — same network required)`);
492
496
  }
497
+ console.log("");
493
498
  }
494
499
 
495
- // ── Step 4: Generate pairing token ────────────────────────────────────
496
- const displayName = hostname().replace(/\.local$/, "");
500
+ // ── Step 3: Generate pairing token + code ─────────────────────────────
497
501
  const token = randomUUID().replace(/-/g, "");
498
502
  validTokens.add(token);
499
- ok("Pairing token generated");
503
+ pairingCode = generatePairingCode();
504
+ pairingData = { url: gatewayURL, token, name: displayName };
500
505
 
501
- // ── Step 5: Show result ───────────────────────────────────────────────
506
+ // ── Step 4: Show connection info ──────────────────────────────────────
507
+ showPairingCode(pairingCode);
508
+
509
+ // Deep link (always show as fallback)
502
510
  const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
503
511
 
504
- showConnectionBox(gatewayURL, token, deepLink, method);
512
+ if (tunnelURL) {
513
+ console.log(` ${DIM}Or paste the deep link in Navigator's address bar:${RESET}`);
514
+ console.log(` ${CYAN}${deepLink}${RESET}`);
515
+ console.log("");
516
+ info(` Tunnel URL: ${tunnelURL}`);
517
+ info(` Token: ${token}`);
518
+ } else {
519
+ info(` Or paste the deep link in Navigator's address bar:`);
520
+ console.log(` ${CYAN}${deepLink}${RESET}`);
521
+ console.log("");
522
+ info(` URL: ${gatewayURL}`);
523
+ info(` Token: ${token}`);
524
+ }
505
525
 
526
+ console.log("");
506
527
  console.log(`${BOLD}${GREEN}Bridge is running.${RESET} Waiting for Navigator to connect...`);
507
528
  console.log(`${DIM}Press Ctrl+C to stop.${RESET}\n`);
508
529
 
509
- // Graceful shutdown
510
- const cleanup = () => {
511
- if (usedTailscaleServe) {
512
- console.log(`${DIM}Removing tailscale serve...${RESET}`);
513
- teardownTailscaleServe(tailscaleHttpsPort);
530
+ // ── Graceful shutdown ─────────────────────────────────────────────────
531
+ const shutdown = () => {
532
+ console.log(`\n${DIM}Shutting down bridge...${RESET}`);
533
+ if (tunnelProcess) {
534
+ tunnelProcess.kill();
514
535
  }
515
536
  server.close();
516
- };
517
-
518
- process.on("SIGINT", () => {
519
- console.log(`\n${DIM}Shutting down bridge...${RESET}`);
520
- cleanup();
521
537
  process.exit(0);
522
- });
538
+ };
523
539
 
524
- process.on("SIGTERM", () => {
525
- cleanup();
526
- process.exit(0);
527
- });
540
+ process.on("SIGINT", shutdown);
541
+ process.on("SIGTERM", shutdown);
528
542
  }
529
543
 
530
544
  main().catch((err) => {
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "2.1.0",
4
- "description": "Self-contained Navigator bridge server connects the Navigator browser to any machine",
3
+ "version": "4.0.0",
4
+ "description": "One-command bridge + tunnel for the Navigator browser works on any machine, any OS",
5
+ "keywords": [
6
+ "browser",
7
+ "cloudflare",
8
+ "gateway",
9
+ "navigator",
10
+ "openclaw",
11
+ "setup",
12
+ "tunnel"
13
+ ],
5
14
  "license": "MIT",
6
15
  "bin": {
7
16
  "openclaw-navigator": "cli.mjs"
@@ -10,14 +19,10 @@
10
19
  "cli.mjs"
11
20
  ],
12
21
  "type": "module",
22
+ "dependencies": {
23
+ "cloudflared": "^0.7.1"
24
+ },
13
25
  "engines": {
14
26
  "node": ">=18.0.0"
15
- },
16
- "keywords": [
17
- "openclaw",
18
- "navigator",
19
- "browser",
20
- "gateway",
21
- "setup"
22
- ]
27
+ }
23
28
  }