11#!/usr/bin/env node
22
3- import { existsSync , readFileSync } from 'node:fs'
3+ import { existsSync , readFileSync , createWriteStream , chmodSync , mkdirSync } from 'node:fs'
44import { dirname , join } from 'node:path'
55import { fileURLToPath } from 'node:url'
6- import { spawnSync } from 'node:child_process'
6+ import { spawnSync , spawn } from 'node:child_process'
7+ import { pipeline } from 'node:stream/promises'
8+ import { homedir } from 'node:os'
79import net from 'node:net'
810
911const DEFAULT_PORT = 38173
@@ -14,6 +16,8 @@ const serverDistEntry = join(rootDir, 'packages', 'server', 'dist', 'index.js')
1416const packageJsonPath = join ( rootDir , 'package.json' )
1517const args = process . argv . slice ( 2 )
1618
19+ let tunnelEnabled = false
20+
1721function readVersion ( ) {
1822 try {
1923 const pkg = JSON . parse ( readFileSync ( packageJsonPath , 'utf-8' ) )
@@ -27,44 +31,42 @@ function printHelp() {
2731 console . log ( `AgentClick CLI
2832
2933Usage:
30- agentclick
31- agentclick --help
34+ agentclick [options]
3235
3336Options:
37+ --tunnel Start a cloudflared tunnel for phone/remote access (no account needed)
38+ --no-tunnel Skip tunnel
3439 --version, -v Show version number
35- --help, -h Show this help message
40+ --help, -h Show this help message
3641
3742Examples:
38- agentclick Start the server (auto-detects port)
43+ agentclick Start the server (local only)
44+ agentclick --tunnel Start with a public tunnel URL for phone access
3945 AGENTCLICK_PORT=4000 agentclick Start on a specific port
4046
4147Environment:
4248 AGENTCLICK_PORT Preferred server port (default: 38173)
4349 PORT Backward-compatible server port override
4450 OPENCLAW_WEBHOOK Webhook URL for agent callbacks
51+ WEB_ORIGIN Override the public URL used in generated session links
4552` )
4653}
4754
4855function parseArgs ( argv ) {
4956 for ( let i = 0 ; i < argv . length ; i += 1 ) {
5057 const arg = argv [ i ]
51- if ( arg === '--help' || arg === '-h' ) {
52- printHelp ( )
53- process . exit ( 0 )
54- }
55- if ( arg === '--version' || arg === '-v' ) {
56- console . log ( readVersion ( ) )
57- process . exit ( 0 )
58- }
58+ if ( arg === '--help' || arg === '-h' ) { printHelp ( ) ; process . exit ( 0 ) }
59+ if ( arg === '--version' || arg === '-v' ) { console . log ( readVersion ( ) ) ; process . exit ( 0 ) }
60+ if ( arg === '--tunnel' ) { tunnelEnabled = true ; continue }
61+ if ( arg === '--no-tunnel' ) { tunnelEnabled = false ; continue }
5962 console . error ( `[agentclick] Unknown argument: ${ arg } ` )
6063 console . error ( '[agentclick] Run "agentclick --help" for usage.' )
6164 process . exit ( 1 )
6265 }
63- return { }
6466}
6567
66- function run ( command , args ) {
67- const result = spawnSync ( command , args , {
68+ function runSync ( command , cmdArgs ) {
69+ const result = spawnSync ( command , cmdArgs , {
6870 cwd : rootDir ,
6971 stdio : 'inherit' ,
7072 env : process . env ,
@@ -83,7 +85,7 @@ parseArgs(args)
8385if ( ! existsSync ( webDistIndex ) || ! existsSync ( serverDistEntry ) ) {
8486 console . log ( '[agentclick] Build artifacts not found, running npm run build...' )
8587 const npmCmd = process . platform === 'win32' ? 'npm.cmd' : 'npm'
86- run ( npmCmd , [ 'run' , 'build' ] )
88+ runSync ( npmCmd , [ 'run' , 'build' ] )
8789}
8890
8991if ( ! existsSync ( serverDistEntry ) ) {
@@ -95,24 +97,18 @@ async function canListen(port) {
9597 return await new Promise ( resolve => {
9698 const server = net . createServer ( )
9799 server . once ( 'error' , err => {
98- if ( ( err ) . code === 'EADDRINUSE' ) {
99- resolve ( false )
100- return
101- }
100+ if ( err . code === 'EADDRINUSE' ) { resolve ( false ) ; return }
102101 console . error ( `[agentclick] Port check failed for ${ port } : ${ err . message } ` )
103102 process . exit ( 1 )
104103 } )
105- server . once ( 'listening' , ( ) => {
106- server . close ( ( ) => resolve ( true ) )
107- } )
104+ server . once ( 'listening' , ( ) => { server . close ( ( ) => resolve ( true ) ) } )
108105 server . listen ( port )
109106 } )
110107}
111108
112109async function getClosestAvailablePort ( preferredPort ) {
113110 const nextPort = preferredPort + 1
114111 if ( await canListen ( nextPort ) ) return nextPort
115- // Fallback to OS-assigned free port (no range scan).
116112 return await new Promise ( ( resolve , reject ) => {
117113 const server = net . createServer ( )
118114 server . once ( 'error' , reject )
@@ -123,10 +119,7 @@ async function getClosestAvailablePort(preferredPort) {
123119 return
124120 }
125121 const freePort = address . port
126- server . close ( err => {
127- if ( err ) reject ( err )
128- else resolve ( freePort )
129- } )
122+ server . close ( err => { if ( err ) reject ( err ) ; else resolve ( freePort ) } )
130123 } )
131124 } )
132125}
@@ -153,39 +146,140 @@ async function resolvePort() {
153146 console . error ( '[agentclick] Invalid AGENTCLICK_PORT/PORT configuration.' )
154147 process . exit ( 1 )
155148 }
156-
157149 const free = await canListen ( configuredPort )
158150 if ( free ) return String ( configuredPort )
159-
160151 if ( await isAgentClickServer ( configuredPort ) ) {
161152 console . log ( `[agentclick] AgentClick already running at http://localhost:${ configuredPort } ; reusing existing server.` )
162153 return null
163154 }
164-
165155 const fallbackPort = await getClosestAvailablePort ( configuredPort )
166156 console . log ( `[agentclick] Port ${ configuredPort } is occupied by another service. Starting AgentClick on ${ fallbackPort } .` )
167157 return String ( fallbackPort )
168158}
169159
170- const childEnv = { ...process . env }
171- const resolvedPort = await resolvePort ( )
172- if ( ! resolvedPort ) {
173- process . exit ( 0 )
160+ // ── cloudflared ───────────────────────────────────────────────────────────────
161+
162+ function getCloudflaredDownloadInfo ( ) {
163+ const { platform, arch } = process
164+ if ( platform === 'darwin' ) {
165+ const file = arch === 'arm64' ? 'cloudflared-darwin-arm64.tgz' : 'cloudflared-darwin-amd64.tgz'
166+ return { url : `https://github.com/cloudflare/cloudflared/releases/latest/download/${ file } ` , tgz : true }
167+ }
168+ if ( platform === 'linux' ) {
169+ const file = arch === 'arm64' ? 'cloudflared-linux-arm64' : 'cloudflared-linux-amd64'
170+ return { url : `https://github.com/cloudflare/cloudflared/releases/latest/download/${ file } ` , tgz : false }
171+ }
172+ if ( platform === 'win32' ) {
173+ return { url : 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' , tgz : false }
174+ }
175+ throw new Error ( `Unsupported platform: ${ platform } ` )
176+ }
177+
178+ async function ensureCloudflared ( ) {
179+ // Check PATH first
180+ const whichResult = spawnSync ( process . platform === 'win32' ? 'where' : 'which' , [ 'cloudflared' ] , { encoding : 'utf-8' } )
181+ if ( whichResult . status === 0 ) return whichResult . stdout . trim ( ) . split ( '\n' ) [ 0 ]
182+
183+ // Check cached binary
184+ const cacheDir = join ( homedir ( ) , '.agentclick' )
185+ const binaryName = process . platform === 'win32' ? 'cloudflared.exe' : 'cloudflared'
186+ const binaryPath = join ( cacheDir , binaryName )
187+ if ( existsSync ( binaryPath ) ) return binaryPath
188+
189+ // Download
190+ console . log ( '[agentclick] Downloading cloudflared (one-time, ~20MB)...' )
191+ if ( ! existsSync ( cacheDir ) ) mkdirSync ( cacheDir , { recursive : true } )
192+ const { url, tgz } = getCloudflaredDownloadInfo ( )
193+ const response = await fetch ( url , { redirect : 'follow' } )
194+ if ( ! response . ok ) throw new Error ( `Failed to download cloudflared: ${ response . status } ${ response . statusText } ` )
195+
196+ if ( tgz ) {
197+ // macOS: download .tgz then extract with system tar
198+ const tgzPath = binaryPath + '.tgz'
199+ const fileStream = createWriteStream ( tgzPath )
200+ await pipeline ( response . body , fileStream )
201+ const result = spawnSync ( 'tar' , [ '-xzf' , tgzPath , '-C' , cacheDir ] , { encoding : 'utf-8' } )
202+ if ( result . status !== 0 ) throw new Error ( `Failed to extract cloudflared: ${ result . stderr } ` )
203+ // tar extracts 'cloudflared' binary into cacheDir
204+ } else {
205+ const fileStream = createWriteStream ( binaryPath )
206+ await pipeline ( response . body , fileStream )
207+ }
208+
209+ if ( process . platform !== 'win32' ) chmodSync ( binaryPath , 0o755 )
210+ console . log ( '[agentclick] cloudflared ready.' )
211+ return binaryPath
212+ }
213+
214+ function startTunnel ( binaryPath , port ) {
215+ return new Promise ( ( resolve , reject ) => {
216+ const cf = spawn ( binaryPath , [ 'tunnel' , '--url' , `http://localhost:${ port } ` ] , {
217+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
218+ } )
219+ const timeout = setTimeout ( ( ) => { cf . kill ( ) ; reject ( new Error ( 'cloudflared did not return a URL within 30s' ) ) } , 30_000 )
220+ function onData ( data ) {
221+ const match = data . toString ( ) . match ( / h t t p s : \/ \/ [ a - z 0 - 9 - ] + \. t r y c l o u d f l a r e \. c o m / )
222+ if ( match ) { clearTimeout ( timeout ) ; resolve ( { url : match [ 0 ] , process : cf } ) }
223+ }
224+ cf . stdout . on ( 'data' , onData )
225+ cf . stderr . on ( 'data' , onData )
226+ cf . on ( 'error' , err => { clearTimeout ( timeout ) ; reject ( err ) } )
227+ cf . on ( 'exit' , code => { if ( code !== 0 && code !== null ) { clearTimeout ( timeout ) ; reject ( new Error ( `cloudflared exited with code ${ code } ` ) ) } } )
228+ } )
229+ }
230+
231+ function printTunnelBanner ( url ) {
232+ const bar = '─' . repeat ( url . length + 4 )
233+ console . log ( `\n ┌${ bar } ┐` )
234+ console . log ( ` │ ${ url } │` )
235+ console . log ( ` └${ bar } ┘` )
236+ console . log ( ' Open this URL on your phone. Valid while this terminal is open.\n' )
174237}
175- childEnv . PORT = resolvedPort
176- childEnv . AGENTCLICK_PORT = resolvedPort
177- process . env . AGENTCLICK_PORT = resolvedPort
238+
239+ // ── main ─────────────────────────────────────────────────────────────────────
240+
241+ const resolvedPort = await resolvePort ( )
242+ if ( ! resolvedPort ) process . exit ( 0 )
243+
244+ const childEnv = { ...process . env , PORT : resolvedPort , AGENTCLICK_PORT : resolvedPort }
178245console . log ( `[agentclick] Using AGENTCLICK_PORT=${ resolvedPort } ` )
179246
180- const result = spawnSync ( process . execPath , [ serverDistEntry ] , {
247+ let tunnelProcess = null
248+
249+ if ( tunnelEnabled ) {
250+ try {
251+ const binaryPath = await ensureCloudflared ( )
252+ console . log ( '[agentclick] Starting tunnel...' )
253+ const { url, process : cf } = await startTunnel ( binaryPath , Number ( resolvedPort ) )
254+ tunnelProcess = cf
255+ childEnv . WEB_ORIGIN = url
256+ printTunnelBanner ( url )
257+ } catch ( err ) {
258+ console . warn ( `[agentclick] Tunnel failed: ${ err . message } ` )
259+ console . warn ( '[agentclick] Continuing without tunnel (local only).' )
260+ }
261+ }
262+
263+ const serverProcess = spawn ( process . execPath , [ serverDistEntry ] , {
181264 cwd : rootDir ,
182265 stdio : 'inherit' ,
183266 env : childEnv ,
184267} )
185- if ( result . error ) {
186- console . error ( '[agentclick] Failed to start server:' , result . error . message )
268+
269+ serverProcess . on ( 'error' , err => {
270+ console . error ( '[agentclick] Failed to start server:' , err . message )
187271 process . exit ( 1 )
272+ } )
273+
274+ serverProcess . on ( 'exit' , code => {
275+ if ( tunnelProcess && ! tunnelProcess . killed ) try { tunnelProcess . kill ( 'SIGTERM' ) } catch { }
276+ process . exit ( code ?? 0 )
277+ } )
278+
279+ function shutdown ( ) {
280+ if ( serverProcess && ! serverProcess . killed ) try { serverProcess . kill ( 'SIGTERM' ) } catch { }
281+ if ( tunnelProcess && ! tunnelProcess . killed ) try { tunnelProcess . kill ( 'SIGTERM' ) } catch { }
188282}
189- if ( typeof result . status === 'number' && result . status !== 0 ) {
190- process . exit ( result . status )
191- }
283+
284+ process . on ( 'SIGINT' , shutdown )
285+ process . on ( 'SIGTERM' , shutdown )
0 commit comments