Skip to content

Commit 41e7905

Browse files
committed
feat: add --tunnel flag with cloudflared auto-download and QR banner
1 parent ffd3641 commit 41e7905

File tree

1 file changed

+140
-46
lines changed

1 file changed

+140
-46
lines changed

bin/agentclick.mjs

Lines changed: 140 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
#!/usr/bin/env node
22

3-
import { existsSync, readFileSync } from 'node:fs'
3+
import { existsSync, readFileSync, createWriteStream, chmodSync, mkdirSync } from 'node:fs'
44
import { dirname, join } from 'node:path'
55
import { 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'
79
import net from 'node:net'
810

911
const DEFAULT_PORT = 38173
@@ -14,6 +16,8 @@ const serverDistEntry = join(rootDir, 'packages', 'server', 'dist', 'index.js')
1416
const packageJsonPath = join(rootDir, 'package.json')
1517
const args = process.argv.slice(2)
1618

19+
let tunnelEnabled = false
20+
1721
function readVersion() {
1822
try {
1923
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
@@ -27,44 +31,42 @@ function printHelp() {
2731
console.log(`AgentClick CLI
2832
2933
Usage:
30-
agentclick
31-
agentclick --help
34+
agentclick [options]
3235
3336
Options:
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
3742
Examples:
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
4147
Environment:
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

4855
function 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)
8385
if (!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

8991
if (!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

112109
async 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(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)
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 }
178245
console.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

Comments
 (0)