ocremote 1.3.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/oc-remote.mjs ADDED
@@ -0,0 +1,2390 @@
1
+ #!/usr/bin/env node
2
+ import http from 'node:http'
3
+ import https from 'node:https'
4
+ import net from 'node:net'
5
+ import os from 'node:os'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import zlib from 'node:zlib'
9
+ import crypto from 'node:crypto'
10
+ import { spawn, execFile } from 'node:child_process'
11
+ import { createRequire } from 'node:module'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { Identity } from './lib/identity.mjs'
14
+ import { RelayClient } from './lib/relay.mjs'
15
+ import { RpcAdapter } from './lib/rpc.mjs'
16
+ import { b64 } from './lib/crypto.mjs'
17
+
18
+ const require = createRequire(import.meta.url)
19
+ const NAME = 'oc-remote'
20
+ const VERSION = '1.3.0'
21
+ const LAUNCH_LABEL = 'com.raul.ocremote'
22
+ const LOG_PATH = path.join(os.homedir(), 'Library', 'Logs', 'oc-remote.log')
23
+ const STARTED_AT = Date.now()
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
25
+ const LOOPBACK = '127.0.0.1'
26
+
27
+ let qrcode = null
28
+ try {
29
+ qrcode = require(path.join(HERE, 'vendor', 'qrcode.js'))
30
+ } catch {
31
+ qrcode = null
32
+ }
33
+
34
+ function augmentPath() {
35
+ const extras = [
36
+ '/opt/homebrew/bin',
37
+ '/usr/local/bin',
38
+ '/usr/bin',
39
+ path.join(os.homedir(), '.local', 'bin'),
40
+ path.join(os.homedir(), '.opencode', 'bin'),
41
+ ]
42
+ const current = (process.env.PATH || '').split(':').filter(Boolean)
43
+ const missing = extras.filter((dir) => dir && !current.includes(dir) && fs.existsSync(dir))
44
+ if (missing.length) process.env.PATH = [...current, ...missing].join(':')
45
+ }
46
+ augmentPath()
47
+
48
+ const HELP = `oc-remote v${VERSION} - remote companion for opencode
49
+
50
+ Usage:
51
+ oc-remote [options] Start the companion (and the daemon entry point)
52
+ npx ocremote --pair First run: check opencode, show the pairing QR
53
+ npx ocremote --daemon Install as a background service (LaunchAgent)
54
+ oc-remote doctor Diagnose every link in the connection chain
55
+ oc-remote status Show config, daemon state and live endpoint
56
+ oc-remote restart Restart the installed LaunchAgent
57
+ oc-remote logs Show the daemon log path and last lines
58
+ oc-remote funnel on|off|status Enable/disable the stable Tailscale Funnel URL
59
+ oc-remote pair Print a fresh pairing QR for OpenCode Remote v2
60
+ oc-remote relay-status Relay identity, connection and paired devices
61
+ oc-remote unpair <pairID> Revoke a paired device (relay + local)
62
+ oc-remote version Print the version
63
+
64
+ Options:
65
+ --dir <path> Project directory used by opencode (default: cwd)
66
+ --port <n> Companion HTTP/SSE proxy port (default: 4190)
67
+ --opencode-port <n> Loopback port for the internal opencode serve (default: 4191)
68
+ --utility-port <n> Loopback-only port for pairing/utility pages (default: port+1)
69
+ --relay <url> Relay v2 URL (wss://relay.example) for anywhere access
70
+ --device-name <name> Name shown on the phone (default: hostname)
71
+ --tunnel <mode> Remote access: auto | funnel | cloudflare | tailscale | none
72
+ auto reuse an active funnel, else cloudflared, else Tailscale, else LAN
73
+ funnel Tailscale Funnel: stable public HTTPS URL (one-time approval)
74
+ cloudflare quick tunnel, downloads cloudflared if needed (zero setup)
75
+ tailscale advertise the Tailscale IP (stable, needs Tailscale app)
76
+ none LAN only (default when nothing else is available)
77
+ --host <ip> Bind address for the companion (default: 0.0.0.0)
78
+ --token <str> Set the pairing token (persisted in config.json)
79
+ --new-token Rotate the pairing token (only way to rotate)
80
+ --ntfy <topic|url> Enable ntfy push notifications and endpoint beacons
81
+ --public-url <url> Public base URL to advertise (skips --tunnel)
82
+ --no-auth Disable token auth (DANGEROUS, LAN becomes open)
83
+ --opencode <path> opencode binary (default: ~/.opencode/bin/opencode)
84
+ --no-mdns Do not publish _ocremote._tcp via Bonjour
85
+ --print-qr Print pairing QR at startup (default)
86
+ --no-print-qr Do not print the pairing QR
87
+ --help Show this help
88
+ `
89
+
90
+ const SUBCOMMANDS = new Set(['doctor', 'status', 'restart', 'logs', 'version', 'funnel', 'pair', 'relay-status', 'unpair'])
91
+
92
+ const VALUE_FLAGS = new Set([
93
+ 'dir',
94
+ 'port',
95
+ 'opencode-port',
96
+ 'utility-port',
97
+ 'host',
98
+ 'token',
99
+ 'ntfy',
100
+ 'public-url',
101
+ 'opencode',
102
+ 'tunnel',
103
+ 'relay',
104
+ 'device-name',
105
+ ])
106
+ const BOOL_FLAGS = new Set(['new-token', 'no-auth', 'no-mdns', 'print-qr', 'no-print-qr', 'help', 'pair', 'daemon'])
107
+ const TUNNEL_MODES = new Set(['auto', 'funnel', 'cloudflare', 'tailscale', 'none'])
108
+ const ALLOWED_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'])
109
+
110
+ let shuttingDown = false
111
+ let server = null
112
+ let child = null
113
+ let mdnsChild = null
114
+ let restartTimer = null
115
+ let restartAttempts = 0
116
+ let activeClients = 0
117
+ let allUrls = []
118
+ let primaryUrl = ''
119
+ let remoteToken = ''
120
+ let internalPassword = ''
121
+ let internalAuth = ''
122
+ let tunnelChild = null
123
+ let tunnelTimer = null
124
+ let tunnelUrl = ''
125
+ let tunnelProvider = null
126
+ let tunnelAttempts = 0
127
+ let quickTunnelUrl = ''
128
+ let funnelMonitorTimer = null
129
+ let transportTimer = null
130
+ let cachedTailscaleIp = null
131
+ let endpointTimer = null
132
+ let lastBeaconKey = ''
133
+ let watchdogTimer = null
134
+ let watchdogFailures = 0
135
+ const sockets = new Set()
136
+
137
+ const AUTH_FAIL_LIMIT = 20
138
+ const AUTH_FAIL_WINDOW = 60_000
139
+ const AUTH_BLOCK_MS = 60_000
140
+ const authFailures = new Map()
141
+
142
+ function log(level, msg) {
143
+ process.stderr.write(`[oc-remote] ${new Date().toISOString()} ${level} ${msg}\n`)
144
+ }
145
+ const info = (m) => log('INFO', m)
146
+ const warn = (m) => log('WARN', m)
147
+ const error = (m) => log('ERROR', m)
148
+
149
+ function randomHex(bytes) {
150
+ return crypto.randomBytes(bytes).toString('hex')
151
+ }
152
+
153
+ function safeEqual(a, b) {
154
+ const ab = Buffer.from(String(a))
155
+ const bb = Buffer.from(String(b))
156
+ if (ab.length !== bb.length) {
157
+ crypto.timingSafeEqual(bb, bb)
158
+ return false
159
+ }
160
+ return crypto.timingSafeEqual(ab, bb)
161
+ }
162
+
163
+ function authBlocked(ip) {
164
+ const now = Date.now()
165
+ const record = authFailures.get(ip)
166
+ if (!record) return false
167
+ if (record.blockedUntil && record.blockedUntil > now) return true
168
+ if (record.blockedUntil) {
169
+ authFailures.delete(ip)
170
+ return false
171
+ }
172
+ if (now - record.windowStart > AUTH_FAIL_WINDOW) {
173
+ authFailures.delete(ip)
174
+ return false
175
+ }
176
+ if (record.count >= AUTH_FAIL_LIMIT) {
177
+ record.blockedUntil = now + AUTH_BLOCK_MS
178
+ return true
179
+ }
180
+ return false
181
+ }
182
+
183
+ function noteAuthFailure(ip) {
184
+ const now = Date.now()
185
+ const record = authFailures.get(ip)
186
+ if (!record || now - record.windowStart > AUTH_FAIL_WINDOW) {
187
+ authFailures.set(ip, { count: 1, windowStart: now, blockedUntil: 0 })
188
+ } else {
189
+ record.count++
190
+ }
191
+ if (authFailures.get(ip).count >= AUTH_FAIL_LIMIT) {
192
+ authFailures.get(ip).blockedUntil = now + AUTH_BLOCK_MS
193
+ warn(`auth: too many failed attempts from ${ip}; blocking for ${AUTH_BLOCK_MS / 1000}s`)
194
+ }
195
+ }
196
+
197
+ function clearAuthFailures(ip) {
198
+ authFailures.delete(ip)
199
+ }
200
+
201
+ function sleep(ms) {
202
+ return new Promise((resolve) => setTimeout(resolve, ms))
203
+ }
204
+
205
+ function ntfyTarget(raw) {
206
+ const text = String(raw || '').trim()
207
+ if (!text) return null
208
+ if (/^https?:\/\//i.test(text)) {
209
+ try {
210
+ const url = new URL(text)
211
+ const segments = url.pathname.split('/').filter(Boolean)
212
+ const topic = segments.pop() || ''
213
+ const base = `${url.origin}${segments.length ? `/${segments.join('/')}` : ''}`
214
+ return { base: base.replace(/\/+$/, ''), topic, query: url.search || '' }
215
+ } catch {
216
+ return null
217
+ }
218
+ }
219
+ return { base: 'https://ntfy.sh', topic: text, query: '' }
220
+ }
221
+
222
+ function publishNtfy(payload) {
223
+ const target = opts.ntfyTarget || (opts.ntfyTarget = ntfyTarget(opts.ntfy))
224
+ if (!target || !target.topic) {
225
+ warn('ntfy: no topic configured; skipping publish')
226
+ return Promise.resolve()
227
+ }
228
+ const url = `${target.base}/${target.query}`
229
+ return fetch(url, {
230
+ method: 'POST',
231
+ headers: { 'content-type': 'application/json' },
232
+ body: JSON.stringify({ topic: target.topic, ...payload }),
233
+ signal: AbortSignal.timeout(5000),
234
+ })
235
+ .then((res) => {
236
+ if (!res.ok) warn(`ntfy: publish failed with HTTP ${res.status}`)
237
+ })
238
+ .catch((err) => warn(`ntfy: publish failed: ${err.message}`))
239
+ }
240
+
241
+ function parseArgs(argv) {
242
+ const flags = {}
243
+ for (let i = 0; i < argv.length; i++) {
244
+ const arg = argv[i]
245
+ if (!arg.startsWith('--')) {
246
+ return { error: `unexpected argument: ${arg}` }
247
+ }
248
+ const eq = arg.indexOf('=')
249
+ const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2)
250
+ if (BOOL_FLAGS.has(key)) {
251
+ flags[key] = true
252
+ continue
253
+ }
254
+ if (!VALUE_FLAGS.has(key)) {
255
+ return { error: `unknown flag: --${key}` }
256
+ }
257
+ const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i]
258
+ if (value === undefined) {
259
+ return { error: `missing value for --${key}` }
260
+ }
261
+ flags[key] = value
262
+ }
263
+ if (flags['no-print-qr']) flags['print-qr'] = false
264
+ return { flags }
265
+ }
266
+
267
+ function configPaths() {
268
+ const dir = process.env.OCREMOTE_CONFIG_DIR
269
+ ? path.resolve(process.env.OCREMOTE_CONFIG_DIR)
270
+ : path.join(os.homedir(), '.config', 'oc-remote')
271
+ return { dir, file: path.join(dir, 'config.json') }
272
+ }
273
+
274
+ function loadConfig() {
275
+ const { file } = configPaths()
276
+ try {
277
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
278
+ return parsed && typeof parsed === 'object' ? parsed : {}
279
+ } catch {
280
+ return {}
281
+ }
282
+ }
283
+
284
+ function saveConfig(cfg) {
285
+ const { dir, file } = configPaths()
286
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
287
+ try {
288
+ fs.chmodSync(dir, 0o700)
289
+ } catch {}
290
+ const tmp = `${file}.tmp`
291
+ fs.writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 })
292
+ fs.renameSync(tmp, file)
293
+ try {
294
+ fs.chmodSync(file, 0o600)
295
+ } catch {}
296
+ }
297
+
298
+ function resolveOpencodeBin(flagValue) {
299
+ if (flagValue) return flagValue
300
+ if (process.env.OPENCODE_BIN) return process.env.OPENCODE_BIN
301
+ const homeBin = path.join(os.homedir(), '.opencode', 'bin', 'opencode')
302
+ if (fs.existsSync(homeBin)) return homeBin
303
+ return 'opencode'
304
+ }
305
+
306
+ function locateBin(bin) {
307
+ if (bin.includes('/')) {
308
+ return fs.existsSync(bin) ? bin : null
309
+ }
310
+ for (const dir of (process.env.PATH || '').split(':')) {
311
+ if (!dir) continue
312
+ const candidate = path.join(dir, bin)
313
+ try {
314
+ fs.accessSync(candidate, fs.constants.X_OK)
315
+ return candidate
316
+ } catch {}
317
+ }
318
+ return null
319
+ }
320
+
321
+ function lanIPv4() {
322
+ const out = []
323
+ for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
324
+ for (const addr of addrs || []) {
325
+ if (addr.family !== 'IPv4' || addr.internal) continue
326
+ if (addr.address.startsWith('169.254.')) continue
327
+ out.push({ name, address: addr.address })
328
+ }
329
+ }
330
+ return out
331
+ }
332
+
333
+ function tailscaleBin() {
334
+ return process.env.OCREMOTE_TAILSCALE || 'tailscale'
335
+ }
336
+
337
+ function execTailscale(args, timeout = 10_000) {
338
+ return new Promise((resolve) => {
339
+ execFile(tailscaleBin(), args, { timeout }, (err, stdout, stderr) => {
340
+ resolve({
341
+ ok: !err,
342
+ code: err && typeof err.code === 'number' ? err.code : err ? 1 : 0,
343
+ stdout: String(stdout || ''),
344
+ stderr: String(stderr || ''),
345
+ })
346
+ })
347
+ })
348
+ }
349
+
350
+ function tailscaleIp() {
351
+ return new Promise((resolve) => {
352
+ execFile(tailscaleBin(), ['ip', '-4'], { timeout: 1500 }, (err, stdout) => {
353
+ if (err) return resolve(null)
354
+ const ip = String(stdout)
355
+ .split('\n')
356
+ .map((line) => line.trim())
357
+ .filter(Boolean)[0]
358
+ resolve(ip && net.isIPv4(ip) ? ip : null)
359
+ })
360
+ })
361
+ }
362
+
363
+ async function tailscaleStatusJson() {
364
+ const result = await execTailscale(['status', '--json'], 5000)
365
+ if (!result.ok) return null
366
+ try {
367
+ return JSON.parse(result.stdout)
368
+ } catch {
369
+ return null
370
+ }
371
+ }
372
+
373
+ async function tailscaleRunning() {
374
+ const status = await tailscaleStatusJson()
375
+ if (!status) return false
376
+ return status.BackendState === 'Running'
377
+ }
378
+
379
+ async function tailscaleDnsName() {
380
+ const status = await tailscaleStatusJson()
381
+ const name = status && status.Self && status.Self.DNSName
382
+ if (typeof name !== 'string' || !name.length) return null
383
+ return name.replace(/\.$/, '')
384
+ }
385
+
386
+ async function funnelStatusJson() {
387
+ const result = await execTailscale(['funnel', 'status', '--json'], 5000)
388
+ if (!result.ok) return null
389
+ const text = result.stdout.trim()
390
+ if (!text || text === '{}') return {}
391
+ try {
392
+ return JSON.parse(text)
393
+ } catch {
394
+ return null
395
+ }
396
+ }
397
+
398
+ async function funnelProxyTarget(dnsName) {
399
+ const status = await funnelStatusJson()
400
+ if (!status || !status.Web || !dnsName) return null
401
+ const entry = status.Web[`${dnsName}:443`]
402
+ if (!entry || !entry.Handlers) return null
403
+ const root = entry.Handlers['/']
404
+ if (!root || typeof root.Proxy !== 'string') return null
405
+ return root.Proxy
406
+ }
407
+
408
+ function funnelProxyMatches(proxy) {
409
+ if (!proxy) return false
410
+ return new RegExp(`(?:127\\.0\\.0\\.1|localhost):${opts.port}(?:/|$)`).test(proxy)
411
+ }
412
+
413
+ async function funnelUrlIfActive() {
414
+ if (!(await tailscaleRunning())) return null
415
+ const dnsName = await tailscaleDnsName()
416
+ if (!dnsName) return null
417
+ const proxy = await funnelProxyTarget(dnsName)
418
+ if (!funnelProxyMatches(proxy)) return null
419
+ return `https://${dnsName}`
420
+ }
421
+
422
+ async function enableFunnel() {
423
+ if (!(await tailscaleRunning())) {
424
+ info('tailscale is not running; trying `tailscale up`...')
425
+ await execTailscale(['up'], 15_000)
426
+ if (!(await tailscaleRunning())) {
427
+ error('tailscale is not connected. Open the Tailscale app and sign in, then retry')
428
+ return null
429
+ }
430
+ }
431
+ const dnsName = await tailscaleDnsName()
432
+ if (!dnsName) {
433
+ error('could not read the Tailscale DNS name (is MagicDNS enabled?)')
434
+ return null
435
+ }
436
+ const active = await funnelUrlIfActive()
437
+ if (active) return active
438
+
439
+ info(`enabling Tailscale Funnel on https://${dnsName} -> http://${LOOPBACK}:${opts.port}`)
440
+ const result = await execTailscale(
441
+ ['funnel', '--bg', '--https=443', `http://${LOOPBACK}:${opts.port}`],
442
+ 20_000
443
+ )
444
+ const output = `${result.stdout}\n${result.stderr}`
445
+ const approval = /https:\/\/login\.tailscale\.com\/[^\s]+/.exec(output)
446
+ if (approval) {
447
+ warn('Funnel needs a one-time approval. Open this URL, approve Funnel, then restart oc-remote:')
448
+ warn(` ${approval[0]}`)
449
+ return null
450
+ }
451
+ if (!result.ok) {
452
+ error(`could not enable Tailscale Funnel: ${(result.stderr || result.stdout).trim() || 'unknown error'}`)
453
+ return null
454
+ }
455
+ for (let attempt = 0; attempt < 5; attempt++) {
456
+ const url = await funnelUrlIfActive()
457
+ if (url) return url
458
+ await sleep(1000)
459
+ }
460
+ const url = await tailscaleDnsName()
461
+ return url ? `https://${url}` : null
462
+ }
463
+
464
+ async function disableFunnel() {
465
+ const dnsName = await tailscaleDnsName()
466
+ const proxy = await funnelProxyTarget(dnsName)
467
+ if (!funnelProxyMatches(proxy)) return
468
+ info('disabling Tailscale Funnel (it pointed at this companion)')
469
+ await execTailscale(['funnel', '--https=443', 'off'], 10_000)
470
+ }
471
+
472
+ function startFunnelMonitor() {
473
+ clearInterval(funnelMonitorTimer)
474
+ funnelMonitorTimer = setInterval(async () => {
475
+ if (shuttingDown || opts.tunnel !== 'funnel') return
476
+ if (await funnelUrlIfActive()) return
477
+ warn('Tailscale Funnel is no longer serving this companion; re-applying')
478
+ const url = await enableFunnel()
479
+ if (url && url !== tunnelUrl) {
480
+ tunnelUrl = url
481
+ rebuildUrls()
482
+ info(`funnel restored: ${tunnelUrl}`)
483
+ printPairing()
484
+ }
485
+ }, 30_000)
486
+ funnelMonitorTimer.unref?.()
487
+ }
488
+
489
+ function isLoopback(req) {
490
+ const addr = req.socket.remoteAddress || ''
491
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
492
+ }
493
+
494
+ function extractToken(req, url) {
495
+ const header = req.headers.authorization
496
+ if (header) {
497
+ const bearer = /^Bearer\s+(.+)$/i.exec(header)
498
+ if (bearer) return bearer[1].trim()
499
+ const basic = /^Basic\s+(.+)$/i.exec(header)
500
+ if (basic) {
501
+ try {
502
+ const decoded = Buffer.from(basic[1], 'base64').toString('utf8')
503
+ const idx = decoded.indexOf(':')
504
+ if (idx >= 0) return decoded.slice(idx + 1)
505
+ } catch {}
506
+ }
507
+ }
508
+ const authToken = url.searchParams.get('auth_token')
509
+ if (authToken) {
510
+ try {
511
+ const decoded = Buffer.from(authToken, 'base64').toString('utf8')
512
+ const idx = decoded.indexOf(':')
513
+ if (idx >= 0) return decoded.slice(idx + 1)
514
+ } catch {}
515
+ }
516
+ const plain = url.searchParams.get('token')
517
+ if (plain) return plain
518
+ return null
519
+ }
520
+
521
+ function authorized(req, url) {
522
+ if (opts.noAuth) return true
523
+ return safeEqual(extractToken(req, url) || '', remoteToken)
524
+ }
525
+
526
+ function sendJson(req, res, status, payload, extraHeaders) {
527
+ const body = JSON.stringify(payload)
528
+ const headers = {
529
+ 'content-type': 'application/json; charset=utf-8',
530
+ 'content-length': Buffer.byteLength(body),
531
+ 'cache-control': 'no-store',
532
+ ...(extraHeaders || {}),
533
+ }
534
+ res.writeHead(status, headers)
535
+ if (req.method === 'HEAD') return res.end()
536
+ res.end(body)
537
+ }
538
+
539
+ function filterRequestHeaders(headers) {
540
+ const drop = new Set([
541
+ 'host',
542
+ 'authorization',
543
+ 'connection',
544
+ 'proxy-connection',
545
+ 'keep-alive',
546
+ 'upgrade',
547
+ 'transfer-encoding',
548
+ 'te',
549
+ 'trailer',
550
+ 'proxy-authorization',
551
+ 'expect',
552
+ ])
553
+ const out = {}
554
+ for (const [key, value] of Object.entries(headers)) {
555
+ if (!drop.has(key.toLowerCase())) out[key] = value
556
+ }
557
+ return out
558
+ }
559
+
560
+ function filterResponseHeaders(headers) {
561
+ const drop = new Set([
562
+ 'connection',
563
+ 'proxy-connection',
564
+ 'keep-alive',
565
+ 'upgrade',
566
+ 'transfer-encoding',
567
+ 'trailer',
568
+ 'proxy-authenticate',
569
+ 'proxy-authorization',
570
+ ])
571
+ const out = {}
572
+ for (const [key, value] of Object.entries(headers)) {
573
+ if (!drop.has(key.toLowerCase())) out[key] = value
574
+ }
575
+ return out
576
+ }
577
+
578
+ function handleProxy(req, res, url) {
579
+ if (!ALLOWED_METHODS.has(req.method)) {
580
+ sendJson(req, res, 405, { error: 'method_not_allowed', method: req.method })
581
+ return
582
+ }
583
+
584
+ const params = new URLSearchParams(url.searchParams)
585
+ params.delete('token')
586
+ params.delete('auth_token')
587
+ const qs = params.toString()
588
+ const targetPath = url.pathname + (qs ? `?${qs}` : '')
589
+
590
+ const headers = filterRequestHeaders(req.headers)
591
+ headers.authorization = internalAuth
592
+
593
+ const isEvent =
594
+ url.pathname === '/event' || url.pathname === '/global/event' || url.pathname === '/api/event'
595
+
596
+ activeClients++
597
+ let counted = false
598
+ const release = () => {
599
+ if (counted) return
600
+ counted = true
601
+ activeClients = Math.max(0, activeClients - 1)
602
+ }
603
+ res.on('close', release)
604
+
605
+ const upstream = http.request(
606
+ {
607
+ host: LOOPBACK,
608
+ port: opts.opencodePort,
609
+ method: req.method,
610
+ path: targetPath,
611
+ headers,
612
+ },
613
+ (upstreamRes) => {
614
+ const outHeaders = filterResponseHeaders(upstreamRes.headers)
615
+ if (isEvent) outHeaders['x-accel-buffering'] = 'no'
616
+ const status = upstreamRes.statusCode || 502
617
+ if (upstreamRes.statusMessage) {
618
+ res.writeHead(status, upstreamRes.statusMessage, outHeaders)
619
+ } else {
620
+ res.writeHead(status, outHeaders)
621
+ }
622
+ res.flushHeaders()
623
+ upstreamRes.on('data', (chunk) => {
624
+ if (!res.write(chunk)) {
625
+ upstreamRes.pause()
626
+ res.once('drain', () => upstreamRes.resume())
627
+ }
628
+ })
629
+ upstreamRes.on('end', () => res.end())
630
+ upstreamRes.on('error', () => res.destroy())
631
+ }
632
+ )
633
+
634
+ upstream.setNoDelay(true)
635
+ upstream.setTimeout(0)
636
+ upstream.on('error', (err) => {
637
+ release()
638
+ if (res.headersSent) {
639
+ res.destroy()
640
+ return
641
+ }
642
+ sendJson(req, res, 502, {
643
+ error: 'bad_gateway',
644
+ message:
645
+ err.code === 'ECONNREFUSED'
646
+ ? 'opencode upstream is not running'
647
+ : err.code === 'ECONNRESET'
648
+ ? 'opencode upstream closed the connection'
649
+ : err.message || String(err),
650
+ })
651
+ })
652
+ req.on('error', () => upstream.destroy())
653
+ res.on('close', () => {
654
+ if (!res.writableEnded) upstream.destroy()
655
+ })
656
+ req.pipe(upstream)
657
+ }
658
+
659
+ function statusPayload() {
660
+ return {
661
+ name: NAME,
662
+ version: VERSION,
663
+ directory: opts.dir,
664
+ uptime: Math.round((Date.now() - STARTED_AT) / 1000),
665
+ port: opts.port,
666
+ opencodePort: opts.opencodePort,
667
+ clients: activeClients,
668
+ tunnel: remoteUrl(),
669
+ tunnelProvider,
670
+ auth: !opts.noAuth,
671
+ utilityPort: opts.utilityPort,
672
+ relay: opts.relay || null,
673
+ deviceID: identity ? identity.deviceID() : null,
674
+ }
675
+ }
676
+
677
+ function remoteUrl() {
678
+ return opts.publicUrl || tunnelUrl || null
679
+ }
680
+
681
+ const CRC_TABLE = (() => {
682
+ const table = new Int32Array(256)
683
+ for (let n = 0; n < 256; n++) {
684
+ let c = n
685
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
686
+ table[n] = c
687
+ }
688
+ return table
689
+ })()
690
+
691
+ function crc32(buf) {
692
+ let c = 0xffffffff
693
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
694
+ return (c ^ 0xffffffff) >>> 0
695
+ }
696
+
697
+ function pngChunk(type, data) {
698
+ const len = Buffer.alloc(4)
699
+ len.writeUInt32BE(data.length, 0)
700
+ const typeBuf = Buffer.from(type, 'latin1')
701
+ const crc = Buffer.alloc(4)
702
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0)
703
+ return Buffer.concat([len, typeBuf, data, crc])
704
+ }
705
+
706
+ function qrPng(qr, scale = 8, margin = 4) {
707
+ const count = qr.getModuleCount()
708
+ const size = (count + margin * 2) * scale
709
+ const raw = Buffer.alloc(size * (size + 1), 0xff)
710
+ for (let y = 0; y < size; y++) raw[y * (size + 1)] = 0
711
+ for (let my = 0; my < count; my++) {
712
+ for (let mx = 0; mx < count; mx++) {
713
+ if (!qr.isDark(my, mx)) continue
714
+ for (let py = 0; py < scale; py++) {
715
+ const row = (margin + my) * scale + py
716
+ const start = row * (size + 1) + 1 + (margin + mx) * scale
717
+ raw.fill(0x00, start, start + scale)
718
+ }
719
+ }
720
+ }
721
+ const ihdr = Buffer.alloc(13)
722
+ ihdr.writeUInt32BE(size, 0)
723
+ ihdr.writeUInt32BE(size, 4)
724
+ ihdr[8] = 8
725
+ ihdr[9] = 0
726
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
727
+ return Buffer.concat([
728
+ signature,
729
+ pngChunk('IHDR', ihdr),
730
+ pngChunk('IDAT', zlib.deflateSync(raw)),
731
+ pngChunk('IEND', Buffer.alloc(0)),
732
+ ])
733
+ }
734
+
735
+ function buildQr(text) {
736
+ const qr = qrcode(0, 'L')
737
+ qr.addData(text)
738
+ qr.make()
739
+ return qr
740
+ }
741
+
742
+ function pairLink() {
743
+ const host = os.hostname()
744
+ const params = [
745
+ 'v=1',
746
+ `name=${encodeURIComponent(host)}`,
747
+ `url=${encodeURIComponent(primaryUrl)}`,
748
+ `token=${encodeURIComponent(remoteToken)}`,
749
+ ]
750
+ if (opts.ntfy) params.push(`ntfy=${encodeURIComponent(String(opts.ntfy))}`)
751
+ if (allUrls.length > 1) {
752
+ params.push(`urls=${encodeURIComponent(allUrls.map((entry) => entry.url).join(','))}`)
753
+ }
754
+ return `ocremote://pair?${params.join('&')}`
755
+ }
756
+
757
+ function escapeHtml(value) {
758
+ return String(value)
759
+ .replace(/&/g, '&amp;')
760
+ .replace(/</g, '&lt;')
761
+ .replace(/>/g, '&gt;')
762
+ .replace(/"/g, '&quot;')
763
+ .replace(/'/g, '&#39;')
764
+ }
765
+
766
+ let identity = null
767
+ let relayClient = null
768
+ let adapter = null
769
+ let utilityServer = null
770
+ let lastInvite = null
771
+
772
+ async function createInvitePayload() {
773
+ if (!relayClient) {
774
+ return { relay: false, url: pairLink(), token: remoteToken }
775
+ }
776
+ const invite = await relayClient.createInvite()
777
+ const url = relayClient.inviteURL(invite)
778
+ lastInvite = { url, expiresAt: invite.expiresAt }
779
+ return {
780
+ relay: true,
781
+ url,
782
+ expiresAt: invite.expiresAt,
783
+ deviceID: relayClient.identity.deviceID(),
784
+ name: relayClient.identity.name(),
785
+ }
786
+ }
787
+
788
+ function currentInviteLink() {
789
+ if (lastInvite && lastInvite.expiresAt > Date.now()) return lastInvite.url
790
+ return null
791
+ }
792
+
793
+ function pairPage() {
794
+ const v2Link = currentInviteLink()
795
+ const link = v2Link || pairLink()
796
+ let qrBlock = '<p class="warn">qrcode.js vendor missing: re-download companion/vendor/qrcode.js</p>'
797
+ if (qrcode) {
798
+ const png = qrPng(buildQr(link))
799
+ qrBlock = `<img class="qr" alt="Pairing QR" src="data:image/png;base64,${png.toString('base64')}">`
800
+ }
801
+ const v2Block = v2Link
802
+ ? `<div class="card"><div class="row"><span class="k">relay</span><code class="v" id="rl">${escapeHtml(opts.relay)}</code></div>
803
+ <div class="row"><span class="k">device</span><code class="v" id="dv">${escapeHtml(relayClient ? relayClient.identity.deviceID() : '')}</code><button onclick="cp('dv')">copy</button></div>
804
+ <div class="row"><span class="k">deep link</span><code class="v" id="dl2">${escapeHtml(v2Link)}</code><button onclick="cp('dl2')">copy</button></div>
805
+ <p>Scan with the app. The invite is single-use and expires in 5 minutes; reload this page for a new one.</p></div>`
806
+ : ''
807
+ const rows = allUrls
808
+ .map(
809
+ (entry) =>
810
+ `<div class="row"><span class="k">${escapeHtml(entry.label)}</span><code class="v" id="u${escapeHtml(entry.label)}">${escapeHtml(entry.url)}</code><button onclick="cp('u${escapeHtml(entry.label)}')">copy</button></div>`
811
+ )
812
+ .join('\n')
813
+ return `<!doctype html>
814
+ <html lang="en">
815
+ <head>
816
+ <meta charset="utf-8">
817
+ <meta name="viewport" content="width=device-width, initial-scale=1">
818
+ <meta name="robots" content="noindex">
819
+ <title>oc-remote pairing</title>
820
+ <style>
821
+ :root { color-scheme: dark; }
822
+ body { margin: 0; padding: 32px 20px; background: #0a0a0a; color: #e5e5e5;
823
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
824
+ .wrap { max-width: 560px; margin: 0 auto; }
825
+ h1 { font-size: 16px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #a3a3a3; }
826
+ .qr { width: 288px; height: 288px; display: block; margin: 24px auto; background: #fff; border-radius: 8px; padding: 12px; image-rendering: pixelated; }
827
+ .card { border: 1px solid #262626; border-radius: 8px; padding: 16px; margin: 16px 0; background: #111; }
828
+ .row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
829
+ .k { color: #737373; min-width: 76px; font-size: 12px; }
830
+ .v { flex: 1; overflow-wrap: anywhere; color: #e5e5e5; }
831
+ button { background: #1f1f1f; color: #d4d4d4; border: 1px solid #333; border-radius: 6px;
832
+ padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer; }
833
+ button:hover { background: #262626; color: #fff; }
834
+ .warn { color: #fbbf24; }
835
+ p { color: #a3a3a3; line-height: 1.5; }
836
+ code { font-family: inherit; font-size: 13px; }
837
+ </style>
838
+ </head>
839
+ <body>
840
+ <div class="wrap">
841
+ <h1>oc-remote &middot; pairing</h1>
842
+ ${qrBlock}
843
+ <p>Scan with the OpenCode Remote iOS app, or copy the values below.</p>
844
+ ${v2Block}
845
+ <div class="card">
846
+ <div class="row"><span class="k">token</span><code class="v" id="tk">${escapeHtml(remoteToken)}</code><button onclick="cp('tk')">copy</button></div>
847
+ ${rows}
848
+ </div>
849
+ <div class="card"><div class="row"><span class="k">deep link</span><code class="v" id="dl">${escapeHtml(pairLink())}</code><button onclick="cp('dl')">copy</button></div></div>
850
+ <p>Served on a loopback-only port that is never exposed through tunnels or the relay.</p>
851
+ </div>
852
+ <script>
853
+ function cp(id) {
854
+ var el = document.getElementById(id);
855
+ var text = el.textContent;
856
+ var done = function () { var b = el.parentNode.querySelector('button'); if (b) { b.textContent = 'copied'; setTimeout(function(){ b.textContent = 'copy'; }, 1200); } };
857
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, fallback); }
858
+ else { fallback(); }
859
+ function fallback() {
860
+ var ta = document.createElement('textarea');
861
+ ta.value = text; document.body.appendChild(ta); ta.select();
862
+ try { document.execCommand('copy'); } catch (e) {}
863
+ document.body.removeChild(ta); done();
864
+ }
865
+ }
866
+ </script>
867
+ </body>
868
+ </html>`
869
+ }
870
+
871
+ function handleRequest(req, res) {
872
+ let url
873
+ try {
874
+ url = new URL(req.url, `http://${LOOPBACK}`)
875
+ } catch {
876
+ sendJson(req, res, 400, { error: 'bad_request' })
877
+ return
878
+ }
879
+
880
+ const pathname = url.pathname
881
+
882
+ if (pathname === '/_ocremote/health') {
883
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
884
+ sendJson(req, res, 405, { error: 'method_not_allowed' })
885
+ return
886
+ }
887
+ sendJson(req, res, 200, { ok: true })
888
+ return
889
+ }
890
+
891
+ {
892
+ const clientIp = req.socket.remoteAddress || 'unknown'
893
+ if (authorized(req, url)) {
894
+ clearAuthFailures(clientIp)
895
+ } else if (authBlocked(clientIp)) {
896
+ sendJson(
897
+ req,
898
+ res,
899
+ 429,
900
+ { error: 'too_many_requests', message: 'too many failed auth attempts; retry in a minute' },
901
+ { 'retry-after': '60' }
902
+ )
903
+ return
904
+ } else {
905
+ noteAuthFailure(clientIp)
906
+ sendJson(req, res, 401, { error: 'unauthorized', message: 'missing or invalid token' })
907
+ return
908
+ }
909
+ }
910
+
911
+ if (pathname === '/_ocremote/status') {
912
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
913
+ sendJson(req, res, 405, { error: 'method_not_allowed' })
914
+ return
915
+ }
916
+ sendJson(req, res, 200, statusPayload())
917
+ return
918
+ }
919
+
920
+ if (pathname === '/_ocremote/pair' || pathname === '/_ocremote/invite') {
921
+ sendJson(req, res, 404, { error: 'not_found', message: 'pairing endpoints live on the loopback-only utility port' })
922
+ return
923
+ }
924
+
925
+ handleProxy(req, res, url)
926
+ }
927
+
928
+ function handleUtilityRequest(req, res) {
929
+ let url
930
+ try {
931
+ url = new URL(req.url, 'http://127.0.0.1')
932
+ } catch {
933
+ sendJson(req, res, 400, { error: 'bad_request' })
934
+ return
935
+ }
936
+ if (url.pathname === '/_ocremote/health') {
937
+ sendJson(req, res, 200, { ok: true })
938
+ return
939
+ }
940
+ if (url.pathname === '/_ocremote/invite') {
941
+ if (req.method !== 'GET' && req.method !== 'POST') {
942
+ sendJson(req, res, 405, { error: 'method_not_allowed' })
943
+ return
944
+ }
945
+ createInvitePayload()
946
+ .then((payload) => sendJson(req, res, 200, payload))
947
+ .catch((err) => sendJson(req, res, 500, { error: 'invite_failed', message: err.message }))
948
+ return
949
+ }
950
+ if (url.pathname === '/_ocremote/pair') {
951
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
952
+ sendJson(req, res, 405, { error: 'method_not_allowed' })
953
+ return
954
+ }
955
+ const ready = relayClient && !currentInviteLink() ? createInvitePayload() : Promise.resolve(null)
956
+ ready
957
+ .then(() => {
958
+ const html = pairPage()
959
+ res.writeHead(200, {
960
+ 'content-type': 'text/html; charset=utf-8',
961
+ 'content-length': Buffer.byteLength(html),
962
+ 'cache-control': 'no-store',
963
+ })
964
+ if (req.method === 'HEAD') return res.end()
965
+ res.end(html)
966
+ })
967
+ .catch((err) => sendJson(req, res, 500, { error: 'page_failed', message: err.message }))
968
+ return
969
+ }
970
+ sendJson(req, res, 404, { error: 'not_found' })
971
+ }
972
+
973
+ async function createUtilityServer() {
974
+ const preferred = opts.utilityPort ?? opts.port + 1
975
+ for (let candidate = preferred; candidate < preferred + 20; candidate++) {
976
+ if (candidate === opts.port || candidate === opts.opencodePort) continue
977
+ if (candidate > 65535) break
978
+ if (!(await portFree(candidate))) continue
979
+ const srv = http.createServer(handleUtilityRequest)
980
+ const ok = await new Promise((resolve) => {
981
+ srv.once('error', (err) => {
982
+ warn(`utility port ${candidate} failed: ${err.message}`)
983
+ resolve(false)
984
+ })
985
+ srv.listen(candidate, LOOPBACK, () => {
986
+ opts.utilityPort = candidate
987
+ resolve(true)
988
+ })
989
+ })
990
+ if (!ok) continue
991
+ info(`pairing page (loopback only): http://${LOOPBACK}:${opts.utilityPort}/_ocremote/pair`)
992
+ return srv
993
+ }
994
+ error('could not start the loopback utility listener; pairing pages are unavailable')
995
+ return null
996
+ }
997
+
998
+ function createProxyServer() {
999
+ const srv = http.createServer(handleRequest)
1000
+ srv.requestTimeout = 0
1001
+ srv.headersTimeout = 60000
1002
+ srv.keepAliveTimeout = 65000
1003
+ srv.timeout = 0
1004
+ srv.on('connection', (socket) => {
1005
+ sockets.add(socket)
1006
+ socket.on('close', () => sockets.delete(socket))
1007
+ })
1008
+ srv.on('clientError', (err, socket) => {
1009
+ try {
1010
+ socket.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\ncontent-length: 0\r\n\r\n')
1011
+ } catch {}
1012
+ })
1013
+ srv.on('upgrade', (req, socket) => {
1014
+ rejectUpgrade(socket)
1015
+ })
1016
+ srv.on('connect', (req, socket) => {
1017
+ rejectUpgrade(socket)
1018
+ })
1019
+ srv.on('error', (err) => {
1020
+ error(`server error: ${err.message}`)
1021
+ })
1022
+ return srv
1023
+ }
1024
+
1025
+ function rejectUpgrade(socket) {
1026
+ const body = JSON.stringify({
1027
+ error: 'not_implemented',
1028
+ message: 'WebSocket/Upgrade is not supported by oc-remote (PTY is out of scope)',
1029
+ })
1030
+ try {
1031
+ socket.write(
1032
+ `HTTP/1.1 501 Not Implemented\r\ncontent-type: application/json; charset=utf-8\r\nconnection: close\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`
1033
+ )
1034
+ } catch {}
1035
+ socket.destroy()
1036
+ }
1037
+
1038
+ function pipePrefixed(stream, prefix) {
1039
+ let buffer = ''
1040
+ stream.setEncoding('utf8')
1041
+ stream.on('data', (chunk) => {
1042
+ buffer += chunk
1043
+ let idx
1044
+ while ((idx = buffer.indexOf('\n')) >= 0) {
1045
+ process.stderr.write(prefix + buffer.slice(0, idx) + '\n')
1046
+ buffer = buffer.slice(idx + 1)
1047
+ }
1048
+ })
1049
+ stream.on('end', () => {
1050
+ if (buffer.length) process.stderr.write(prefix + buffer + '\n')
1051
+ })
1052
+ stream.on('error', () => {})
1053
+ }
1054
+
1055
+ function launchOpencode() {
1056
+ const args = [
1057
+ 'serve',
1058
+ '--hostname',
1059
+ LOOPBACK,
1060
+ '--port',
1061
+ String(opts.opencodePort),
1062
+ '--print-logs',
1063
+ '--log-level',
1064
+ 'INFO',
1065
+ ]
1066
+ info(`starting opencode: ${opts.opencodeBin} ${args.join(' ')} (cwd: ${opts.dir})`)
1067
+ child = spawn(opts.opencodeBin, args, {
1068
+ cwd: opts.dir,
1069
+ env: {
1070
+ ...process.env,
1071
+ OPENCODE_SERVER_USERNAME: 'opencode',
1072
+ OPENCODE_SERVER_PASSWORD: internalPassword,
1073
+ },
1074
+ stdio: ['ignore', 'pipe', 'pipe'],
1075
+ })
1076
+ child.on('error', (err) => {
1077
+ error(`failed to spawn opencode: ${err.message}`)
1078
+ })
1079
+ child.on('exit', (code, signal) => {
1080
+ child = null
1081
+ if (shuttingDown) return
1082
+ scheduleRestart(signal ? `signal ${signal}` : `code ${code}`)
1083
+ })
1084
+ if (child.stdout) pipePrefixed(child.stdout, '[opencode] ')
1085
+ if (child.stderr) pipePrefixed(child.stderr, '[opencode] ')
1086
+ }
1087
+
1088
+ function scheduleRestart(reason) {
1089
+ restartAttempts++
1090
+ const delay = Math.min(15000, 1000 * 2 ** Math.min(restartAttempts - 1, 4))
1091
+ warn(`opencode exited (${reason}); restarting in ${delay}ms (attempt ${restartAttempts})`)
1092
+ restartTimer = setTimeout(async () => {
1093
+ if (shuttingDown) return
1094
+ launchOpencode()
1095
+ const health = await waitHealthy(30000)
1096
+ if (health) {
1097
+ restartAttempts = 0
1098
+ info(`opencode restarted (v${health.version})`)
1099
+ }
1100
+ }, delay)
1101
+ }
1102
+
1103
+ function waitHealthy(timeoutMs) {
1104
+ const deadline = Date.now() + timeoutMs
1105
+ const attempt = () =>
1106
+ new Promise((resolve) => {
1107
+ const req = http.request(
1108
+ {
1109
+ host: LOOPBACK,
1110
+ port: opts.opencodePort,
1111
+ path: '/global/health',
1112
+ method: 'GET',
1113
+ headers: { authorization: internalAuth },
1114
+ timeout: 2000,
1115
+ },
1116
+ (res) => {
1117
+ let data = ''
1118
+ res.setEncoding('utf8')
1119
+ res.on('data', (chunk) => {
1120
+ data += chunk
1121
+ })
1122
+ res.on('end', () => {
1123
+ try {
1124
+ const parsed = JSON.parse(data)
1125
+ resolve(parsed && parsed.healthy ? parsed : null)
1126
+ } catch {
1127
+ resolve(null)
1128
+ }
1129
+ })
1130
+ }
1131
+ )
1132
+ req.on('error', () => resolve(null))
1133
+ req.on('timeout', () => {
1134
+ req.destroy()
1135
+ resolve(null)
1136
+ })
1137
+ req.end()
1138
+ })
1139
+
1140
+ return (async () => {
1141
+ while (Date.now() < deadline) {
1142
+ if (shuttingDown) return null
1143
+ const health = await attempt()
1144
+ if (health) return health
1145
+ await sleep(400)
1146
+ }
1147
+ return null
1148
+ })()
1149
+ }
1150
+
1151
+ function portFree(port) {
1152
+ return new Promise((resolve) => {
1153
+ const probe = net.createServer()
1154
+ probe.once('error', () => resolve(false))
1155
+ probe.once('listening', () => probe.close(() => resolve(true)))
1156
+ probe.listen(port, LOOPBACK)
1157
+ })
1158
+ }
1159
+
1160
+ function configBinDir() {
1161
+ return path.join(configPaths().dir, 'bin')
1162
+ }
1163
+
1164
+ function cloudflaredLocalBin() {
1165
+ return path.join(configBinDir(), process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared')
1166
+ }
1167
+
1168
+ function findCloudflared() {
1169
+ if (process.env.OCREMOTE_CLOUDFLARED) {
1170
+ const override = process.env.OCREMOTE_CLOUDFLARED
1171
+ return fs.existsSync(override) ? override : null
1172
+ }
1173
+ const local = cloudflaredLocalBin()
1174
+ if (fs.existsSync(local)) return local
1175
+ return locateBin('cloudflared')
1176
+ }
1177
+
1178
+ function downloadFile(url, dest, redirects = 0) {
1179
+ return new Promise((resolve, reject) => {
1180
+ if (redirects > 5) return reject(new Error('too many redirects'))
1181
+ const req = https.get(url, { headers: { 'user-agent': `${NAME}/${VERSION}` } }, (res) => {
1182
+ const status = res.statusCode || 0
1183
+ if (status >= 300 && status < 400 && res.headers.location) {
1184
+ res.resume()
1185
+ resolve(downloadFile(new URL(res.headers.location, url).toString(), dest, redirects + 1))
1186
+ return
1187
+ }
1188
+ if (status !== 200) {
1189
+ res.resume()
1190
+ reject(new Error(`download failed with HTTP ${status}`))
1191
+ return
1192
+ }
1193
+ const file = fs.createWriteStream(dest, { mode: 0o755 })
1194
+ res.pipe(file)
1195
+ file.on('finish', () => file.close(() => resolve()))
1196
+ file.on('error', reject)
1197
+ res.on('error', reject)
1198
+ })
1199
+ req.on('error', reject)
1200
+ req.setTimeout(120000, () => req.destroy(new Error('download timed out')))
1201
+ })
1202
+ }
1203
+
1204
+ async function ensureCloudflared() {
1205
+ const existing = findCloudflared()
1206
+ if (existing) return existing
1207
+ const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
1208
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
1209
+ throw new Error(`automatic cloudflared install is not supported on ${process.platform}; install it manually`)
1210
+ }
1211
+ const asset = process.platform === 'darwin' ? `cloudflared-darwin-${arch}.tgz` : `cloudflared-linux-${arch}`
1212
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`
1213
+ info(`downloading cloudflared (${asset}) ...`)
1214
+ fs.mkdirSync(configBinDir(), { recursive: true, mode: 0o700 })
1215
+ const tmp = path.join(os.tmpdir(), `oc-remote-cloudflared-${Date.now()}`)
1216
+ await downloadFile(url, tmp)
1217
+ if (asset.endsWith('.tgz')) {
1218
+ await new Promise((resolve, reject) => {
1219
+ execFile('tar', ['-xzf', tmp, '-C', configBinDir()], (err) => (err ? reject(err) : resolve()))
1220
+ })
1221
+ } else {
1222
+ fs.copyFileSync(tmp, cloudflaredLocalBin())
1223
+ }
1224
+ fs.chmodSync(cloudflaredLocalBin(), 0o755)
1225
+ try {
1226
+ fs.unlinkSync(tmp)
1227
+ } catch {}
1228
+ const bin = findCloudflared()
1229
+ if (!bin) throw new Error('cloudflared install failed')
1230
+ return bin
1231
+ }
1232
+
1233
+ function readLines(stream, onLine) {
1234
+ let buffer = ''
1235
+ stream.setEncoding('utf8')
1236
+ stream.on('data', (chunk) => {
1237
+ buffer += chunk
1238
+ let idx
1239
+ while ((idx = buffer.indexOf('\n')) >= 0) {
1240
+ onLine(buffer.slice(0, idx))
1241
+ buffer = buffer.slice(idx + 1)
1242
+ }
1243
+ })
1244
+ }
1245
+
1246
+ async function startTunnel() {
1247
+ if (opts.publicUrl) {
1248
+ info('--public-url set; skipping tunnel startup')
1249
+ return
1250
+ }
1251
+ if (opts.tunnel === 'none') return
1252
+
1253
+ if (opts.tunnel === 'funnel') {
1254
+ const url = await enableFunnel()
1255
+ if (!url) {
1256
+ warn('funnel unavailable; staying on LAN/Tailscale candidates')
1257
+ return
1258
+ }
1259
+ tunnelProvider = 'funnel'
1260
+ tunnelUrl = url
1261
+ cachedTailscaleIp = await tailscaleIp()
1262
+ rebuildUrls()
1263
+ info(`stable remote URL via Tailscale Funnel: ${tunnelUrl}`)
1264
+ printPairing()
1265
+ startFunnelMonitor()
1266
+ return
1267
+ }
1268
+
1269
+ if (opts.tunnel === 'tailscale') {
1270
+ const ip = await tailscaleIp()
1271
+ if (!ip) {
1272
+ warn('--tunnel tailscale: tailscale is not installed or not connected; falling back to LAN')
1273
+ return
1274
+ }
1275
+ tunnelProvider = 'tailscale'
1276
+ tunnelUrl = `http://${ip}:${opts.port}`
1277
+ rebuildUrls()
1278
+ info(`remote access via Tailscale: ${tunnelUrl}`)
1279
+ printPairing()
1280
+ return
1281
+ }
1282
+
1283
+ if (opts.tunnel === 'auto') {
1284
+ const activeFunnel = await funnelUrlIfActive()
1285
+ if (activeFunnel) {
1286
+ tunnelProvider = 'funnel'
1287
+ tunnelUrl = activeFunnel
1288
+ rebuildUrls()
1289
+ info(`stable remote URL via existing Tailscale Funnel: ${tunnelUrl}`)
1290
+ printPairing()
1291
+ return
1292
+ }
1293
+ }
1294
+
1295
+ const installed = findCloudflared()
1296
+ if (!installed && opts.tunnel === 'auto') {
1297
+ if (cachedTailscaleIp) {
1298
+ tunnelProvider = 'tailscale'
1299
+ tunnelUrl = `http://${cachedTailscaleIp}:${opts.port}`
1300
+ rebuildUrls()
1301
+ info(`remote access via Tailscale: ${tunnelUrl}`)
1302
+ printPairing()
1303
+ return
1304
+ }
1305
+ info('remote access: LAN only - run `oc-remote --tunnel funnel` (stable URL) or --tunnel cloudflare')
1306
+ return
1307
+ }
1308
+
1309
+ let bin = installed
1310
+ if (!bin) {
1311
+ try {
1312
+ bin = await ensureCloudflared()
1313
+ info(`cloudflared ready: ${bin}`)
1314
+ } catch (err) {
1315
+ error(`could not install cloudflared: ${err.message}`)
1316
+ return
1317
+ }
1318
+ }
1319
+
1320
+ tunnelProvider = 'cloudflare'
1321
+ spawnCloudflared(bin)
1322
+ }
1323
+
1324
+ function spawnCloudflared(bin) {
1325
+ if (shuttingDown) return
1326
+ info('starting cloudflare quick tunnel...')
1327
+ tunnelChild = spawn(bin, ['tunnel', '--url', `http://${LOOPBACK}:${opts.port}`, '--no-autoupdate'], {
1328
+ stdio: ['ignore', 'pipe', 'pipe'],
1329
+ })
1330
+ const onLine = (line) => {
1331
+ const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(line)
1332
+ if (!match) return
1333
+ const url = match[0]
1334
+ quickTunnelUrl = url
1335
+ if (url === tunnelUrl) return
1336
+ if (tunnelProvider === 'funnel') {
1337
+ rebuildUrls()
1338
+ return
1339
+ }
1340
+ tunnelUrl = url
1341
+ tunnelAttempts = 0
1342
+ rebuildUrls()
1343
+ info(`cloudflare quick tunnel ready: ${tunnelUrl}`)
1344
+ printPairing()
1345
+ }
1346
+ if (tunnelChild.stdout) readLines(tunnelChild.stdout, onLine)
1347
+ if (tunnelChild.stderr) readLines(tunnelChild.stderr, onLine)
1348
+ tunnelChild.on('error', (err) => error(`cloudflared failed to start: ${err.message}`))
1349
+ tunnelChild.on('exit', (code, signal) => {
1350
+ tunnelChild = null
1351
+ if (shuttingDown || opts.tunnel === 'none') return
1352
+ tunnelUrl = tunnelProvider === 'funnel' ? tunnelUrl : ''
1353
+ quickTunnelUrl = ''
1354
+ rebuildUrls()
1355
+ tunnelAttempts++
1356
+ const delay = Math.min(30000, 2000 * 2 ** Math.min(tunnelAttempts - 1, 4))
1357
+ warn(`cloudflared exited (${signal || code}); restarting in ${delay}ms`)
1358
+ tunnelTimer = setTimeout(() => spawnCloudflared(bin), delay)
1359
+ })
1360
+ }
1361
+
1362
+ function stopTunnel() {
1363
+ clearTimeout(tunnelTimer)
1364
+ tunnelTimer = null
1365
+ if (tunnelChild) {
1366
+ try {
1367
+ tunnelChild.kill('SIGTERM')
1368
+ } catch {}
1369
+ tunnelChild = null
1370
+ }
1371
+ }
1372
+
1373
+ async function computeUrls() {
1374
+ cachedTailscaleIp = await tailscaleIp()
1375
+ rebuildUrls()
1376
+ }
1377
+
1378
+ function rebuildUrls() {
1379
+ const urls = []
1380
+ if (opts.publicUrl) urls.push({ url: opts.publicUrl, label: 'public' })
1381
+ if (tunnelUrl && !opts.publicUrl) {
1382
+ urls.push({ url: tunnelUrl, label: tunnelProvider === 'tailscale' ? 'tailscale' : 'tunnel' })
1383
+ }
1384
+ if (quickTunnelUrl && quickTunnelUrl !== tunnelUrl) {
1385
+ urls.push({ url: quickTunnelUrl, label: 'tunnel' })
1386
+ }
1387
+ if (cachedTailscaleIp && !urls.some((entry) => entry.url.includes(cachedTailscaleIp))) {
1388
+ urls.push({ url: `http://${cachedTailscaleIp}:${opts.port}`, label: 'tailscale' })
1389
+ }
1390
+ const seen = new Set()
1391
+ for (const entry of lanIPv4()) {
1392
+ if (seen.has(entry.address)) continue
1393
+ seen.add(entry.address)
1394
+ if (urls.some((existing) => existing.url.includes(entry.address))) continue
1395
+ urls.push({ url: `http://${entry.address}:${opts.port}`, label: entry.name })
1396
+ }
1397
+ urls.push({ url: `http://${LOOPBACK}:${opts.port}`, label: 'loopback' })
1398
+ allUrls = urls
1399
+ const next = urls[0].url.replace(/\/+$/, '')
1400
+ const changed = next !== primaryUrl
1401
+ primaryUrl = next
1402
+ if (changed) {
1403
+ writeEndpointFile()
1404
+ publishBeacon()
1405
+ }
1406
+ }
1407
+
1408
+ function endpointId() {
1409
+ return crypto.createHash('sha256').update(`oc-remote:${remoteToken}`).digest('hex').slice(0, 12)
1410
+ }
1411
+
1412
+ function endpointPayload() {
1413
+ return {
1414
+ type: 'endpoint',
1415
+ id: endpointId(),
1416
+ name: os.hostname(),
1417
+ version: VERSION,
1418
+ url: primaryUrl,
1419
+ urls: allUrls.map((entry) => ({ url: entry.url, label: entry.label })),
1420
+ provider: opts.publicUrl ? 'public' : tunnelProvider || 'lan',
1421
+ auth: !opts.noAuth,
1422
+ port: opts.port,
1423
+ ts: Date.now(),
1424
+ }
1425
+ }
1426
+
1427
+ function writeEndpointFile() {
1428
+ try {
1429
+ const { dir } = configPaths()
1430
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
1431
+ const file = path.join(dir, 'endpoint.json')
1432
+ const tmp = `${file}.tmp`
1433
+ fs.writeFileSync(tmp, `${JSON.stringify(endpointPayload(), null, 2)}\n`, { mode: 0o600 })
1434
+ fs.renameSync(tmp, file)
1435
+ } catch (err) {
1436
+ warn(`could not write endpoint.json: ${err.message}`)
1437
+ }
1438
+ }
1439
+
1440
+ function publishBeacon() {
1441
+ if (!opts.ntfyUrl || shuttingDown) return
1442
+ const payload = endpointPayload()
1443
+ const key = payload.url
1444
+ if (key === lastBeaconKey) return
1445
+ lastBeaconKey = key
1446
+ info(`beacon: publishing ${payload.url} [${payload.provider}] to ntfy`)
1447
+ publishNtfy({
1448
+ title: 'oc-remote endpoint',
1449
+ message: JSON.stringify(payload),
1450
+ tags: ['ocremote-endpoint'],
1451
+ priority: 1,
1452
+ })
1453
+ }
1454
+
1455
+ function startEndpointBeacon() {
1456
+ writeEndpointFile()
1457
+ publishBeacon()
1458
+ clearInterval(endpointTimer)
1459
+ endpointTimer = setInterval(() => {
1460
+ if (shuttingDown) return
1461
+ computeUrls()
1462
+ writeEndpointFile()
1463
+ lastBeaconKey = ''
1464
+ publishBeacon()
1465
+ }, 5 * 60_000)
1466
+ endpointTimer.unref?.()
1467
+ }
1468
+
1469
+ function startTransportWatcher() {
1470
+ clearInterval(transportTimer)
1471
+ transportTimer = setInterval(async () => {
1472
+ if (shuttingDown || opts.publicUrl || opts.tunnel !== 'auto') return
1473
+ const active = await funnelUrlIfActive()
1474
+ if (active && active !== tunnelUrl) {
1475
+ tunnelProvider = 'funnel'
1476
+ tunnelUrl = active
1477
+ rebuildUrls()
1478
+ info(`stable remote URL via existing Tailscale Funnel: ${tunnelUrl}`)
1479
+ printPairing()
1480
+ return
1481
+ }
1482
+ if (!active && tunnelProvider === 'funnel') {
1483
+ tunnelProvider = quickTunnelUrl ? 'cloudflare' : null
1484
+ tunnelUrl = quickTunnelUrl
1485
+ rebuildUrls()
1486
+ warn(
1487
+ tunnelUrl
1488
+ ? `Tailscale Funnel went away; falling back to ${tunnelUrl}`
1489
+ : 'Tailscale Funnel went away; falling back to LAN/Tailscale candidates'
1490
+ )
1491
+ printPairing()
1492
+ }
1493
+ }, 60_000)
1494
+ transportTimer.unref?.()
1495
+ }
1496
+
1497
+ function startWatchdog() {
1498
+ clearInterval(watchdogTimer)
1499
+ watchdogTimer = setInterval(() => {
1500
+ if (shuttingDown || !child || child.exitCode !== null) return
1501
+ const req = http.request(
1502
+ {
1503
+ host: LOOPBACK,
1504
+ port: opts.opencodePort,
1505
+ path: '/global/health',
1506
+ method: 'GET',
1507
+ headers: { authorization: internalAuth },
1508
+ timeout: 3000,
1509
+ },
1510
+ (res) => {
1511
+ res.resume()
1512
+ if (res.statusCode && res.statusCode < 500) {
1513
+ watchdogFailures = 0
1514
+ return
1515
+ }
1516
+ noteWatchdogFailure()
1517
+ }
1518
+ )
1519
+ req.on('timeout', () => req.destroy(new Error('timeout')))
1520
+ req.on('error', () => noteWatchdogFailure())
1521
+ req.end()
1522
+ }, 30_000)
1523
+ watchdogTimer.unref?.()
1524
+ }
1525
+
1526
+ function noteWatchdogFailure() {
1527
+ watchdogFailures++
1528
+ warn(`watchdog: opencode health check failed (${watchdogFailures}/3)`)
1529
+ if (watchdogFailures < 3) return
1530
+ watchdogFailures = 0
1531
+ if (!child || child.exitCode !== null) return
1532
+ error('watchdog: opencode is unresponsive; restarting it')
1533
+ try {
1534
+ child.kill('SIGKILL')
1535
+ } catch {}
1536
+ }
1537
+
1538
+ function printPairing() {
1539
+ for (const entry of allUrls) info(`url: ${entry.url} [${entry.label}]`)
1540
+ info(`pairing token: ${remoteToken}`)
1541
+ const publicEntry = allUrls.find((entry) => entry.label === 'public' || entry.label === 'tunnel')
1542
+ if (publicEntry) {
1543
+ const stable = tunnelProvider === 'funnel' || publicEntry.label === 'public'
1544
+ info(
1545
+ `remote access: ${publicEntry.url} (${stable ? 'stable, reachable from anywhere' : 'reachable from anywhere; the URL changes when the tunnel restarts'})`
1546
+ )
1547
+ } else if (tunnelProvider === 'tailscale') {
1548
+ info('remote access: Tailscale IP above (needs the Tailscale app on your phone)')
1549
+ } else {
1550
+ info('remote access: LAN only - run `oc-remote --tunnel funnel` (stable URL) or --tunnel cloudflare')
1551
+ }
1552
+ info(`pairing deep link: ${pairLink()}`)
1553
+ if (!opts.printQr) return
1554
+ if (!qrcode) {
1555
+ warn('QR unavailable: companion/vendor/qrcode.js not found')
1556
+ return
1557
+ }
1558
+ const ascii = buildQr(pairLink()).createASCII(1, 4)
1559
+ process.stderr.write(`${ascii}\n`)
1560
+ }
1561
+
1562
+ function printInviteQr(link) {
1563
+ if (!opts.printQr || !qrcode) return
1564
+ process.stderr.write(`${buildQr(link).createASCII(1, 4)}\n`)
1565
+ }
1566
+
1567
+ function handleRelayEvent(event) {
1568
+ if (event.type === 'relay_connected') return
1569
+ if (event.type === 'relay_disconnected') return
1570
+ if (event.type === 'paired') {
1571
+ info(`relay: paired ${event.clientID}${event.name ? ` (${event.name})` : ''}`)
1572
+ return
1573
+ }
1574
+ if (event.type === 'session_open') {
1575
+ info(`relay: client session ${event.session} (${event.clientID})`)
1576
+ return
1577
+ }
1578
+ }
1579
+
1580
+ async function startRelay() {
1581
+ if (!opts.relay) return
1582
+ identity = new Identity(configPaths().dir).ensure(opts.deviceName || os.hostname().replace(/\.local$/i, ''))
1583
+ adapter = new RpcAdapter({
1584
+ baseUrl: `http://${LOOPBACK}:${opts.opencodePort}`,
1585
+ authHeader: internalAuth,
1586
+ logger: warn,
1587
+ })
1588
+ relayClient = new RelayClient({
1589
+ relayUrl: opts.relay,
1590
+ identity,
1591
+ adapter,
1592
+ logger: info,
1593
+ invites: new Map(),
1594
+ onEvent: handleRelayEvent,
1595
+ })
1596
+ try {
1597
+ await relayClient.registerDevice(VERSION)
1598
+ info(`relay: registered device ${identity.deviceID()} (${identity.name()})`)
1599
+ } catch (err) {
1600
+ error(`relay registration failed: ${err.message}`)
1601
+ }
1602
+ relayClient.start()
1603
+ const payload = await createInvitePayload()
1604
+ info(`relay: ${opts.relay}`)
1605
+ info(`pairing v2 link: ${payload.url}`)
1606
+ printInviteQr(payload.url)
1607
+ }
1608
+
1609
+ function printBanner(health) {
1610
+ const hostname = os.hostname()
1611
+ info(`${NAME} v${VERSION} on ${hostname} (${os.platform()} ${os.release()})`)
1612
+ info(`project directory: ${opts.dir}`)
1613
+ info(`opencode: ${opts.opencodeBin} v${health.version} -> http://${LOOPBACK}:${opts.opencodePort} (loopback only)`)
1614
+ info(`proxy listening: ${opts.host}:${opts.port}`)
1615
+ if (opts.noAuth) warn('AUTH DISABLED: anyone who can reach this port controls opencode')
1616
+ for (const line of opts.noAuth
1617
+ ? ['pair page: disabled (--no-auth)']
1618
+ : [`pair page (loopback): http://${LOOPBACK}:${opts.port}/_ocremote/pair`])
1619
+ info(line)
1620
+ if (opts.relay) info(`relay: ${opts.relay} (device ${opts.deviceName || os.hostname()})`)
1621
+ info('self-check: oc-remote doctor')
1622
+ printPairing()
1623
+ }
1624
+
1625
+ function startMdns() {
1626
+ if (!opts.mdns) return
1627
+ const dnsSd = '/usr/bin/dns-sd'
1628
+ if (!fs.existsSync(dnsSd)) {
1629
+ warn('mDNS: /usr/bin/dns-sd not found; skipping Bonjour registration')
1630
+ return
1631
+ }
1632
+ const hostLabel = os.hostname().replace(/\.local\.?$/i, '')
1633
+ const serviceName = `${NAME} (${hostLabel})`
1634
+ mdnsChild = spawn(dnsSd, ['-R', serviceName, '_ocremote._tcp', 'local', String(opts.port), 'v=1'], {
1635
+ stdio: ['ignore', 'pipe', 'pipe'],
1636
+ })
1637
+ mdnsChild.on('error', (err) => warn(`mDNS: ${err.message}`))
1638
+ mdnsChild.on('exit', (code, signal) => {
1639
+ mdnsChild = null
1640
+ if (!shuttingDown && code !== 0) warn(`mDNS registration exited (${signal || code})`)
1641
+ })
1642
+ if (mdnsChild.stdout) pipePrefixed(mdnsChild.stdout, '[dns-sd] ')
1643
+ if (mdnsChild.stderr) pipePrefixed(mdnsChild.stderr, '[dns-sd] ')
1644
+ info(`mDNS: publishing "_ocremote._tcp.local" as "${serviceName}" on port ${opts.port}`)
1645
+ }
1646
+
1647
+ function startNtfy() {
1648
+ if (!opts.ntfy) return
1649
+ const busy = new Set()
1650
+ const publish = (payload) => publishNtfy(payload)
1651
+
1652
+ const onEvent = (event) => {
1653
+ const props = event.properties || {}
1654
+ if (event.type === 'permission.asked') {
1655
+ const patterns = Array.isArray(props.patterns) ? props.patterns.join(', ') : ''
1656
+ const message = `${props.permission || 'permission'} ${patterns}`.trim()
1657
+ info(`ntfy: permission.asked -> push notification`)
1658
+ publish({
1659
+ title: 'opencode: permission required',
1660
+ message: message || 'permission required',
1661
+ tags: ['warning'],
1662
+ priority: 4,
1663
+ ...(primaryUrl ? { click: primaryUrl } : {}),
1664
+ })
1665
+ return
1666
+ }
1667
+ if (event.type === 'session.status') {
1668
+ const status = props.status && props.status.type
1669
+ if (status === 'busy') busy.add(props.sessionID)
1670
+ else busy.delete(props.sessionID)
1671
+ return
1672
+ }
1673
+ if (event.type === 'session.idle') {
1674
+ if (busy.delete(props.sessionID)) {
1675
+ info(`ntfy: session.idle after busy -> push notification`)
1676
+ publish({
1677
+ title: 'opencode: task finished',
1678
+ message: `session ${props.sessionID} is idle`,
1679
+ tags: ['white_check_mark'],
1680
+ priority: 3,
1681
+ ...(primaryUrl ? { click: primaryUrl } : {}),
1682
+ })
1683
+ }
1684
+ return
1685
+ }
1686
+ if (event.type === 'session.deleted') busy.delete(props.sessionID)
1687
+ }
1688
+
1689
+ const connect = () => {
1690
+ if (shuttingDown) return
1691
+ const req = http.request(
1692
+ {
1693
+ host: LOOPBACK,
1694
+ port: opts.opencodePort,
1695
+ path: '/event',
1696
+ method: 'GET',
1697
+ headers: { authorization: internalAuth, accept: 'text/event-stream' },
1698
+ },
1699
+ (res) => {
1700
+ if (res.statusCode !== 200) {
1701
+ warn(`ntfy: event stream returned HTTP ${res.statusCode}; retrying in 5s`)
1702
+ res.resume()
1703
+ setTimeout(connect, 5000)
1704
+ return
1705
+ }
1706
+ info(`ntfy: subscribed to opencode events (topic ${opts.ntfyUrl})`)
1707
+ let buffer = ''
1708
+ res.setEncoding('utf8')
1709
+ res.on('data', (chunk) => {
1710
+ buffer += chunk
1711
+ let idx
1712
+ while ((idx = buffer.indexOf('\n\n')) >= 0) {
1713
+ const block = buffer.slice(0, idx)
1714
+ buffer = buffer.slice(idx + 2)
1715
+ for (const line of block.split('\n')) {
1716
+ if (!line.startsWith('data:')) continue
1717
+ try {
1718
+ onEvent(JSON.parse(line.slice(5).trim()))
1719
+ } catch {}
1720
+ }
1721
+ }
1722
+ })
1723
+ res.on('end', () => {
1724
+ if (!shuttingDown) setTimeout(connect, 5000)
1725
+ })
1726
+ res.on('error', () => {})
1727
+ }
1728
+ )
1729
+ req.on('error', (err) => {
1730
+ if (shuttingDown) return
1731
+ warn(`ntfy: event stream error: ${err.message}; retrying in 5s`)
1732
+ setTimeout(connect, 5000)
1733
+ })
1734
+ req.end()
1735
+ }
1736
+ connect()
1737
+ }
1738
+
1739
+ function shutdown(signal) {
1740
+ if (shuttingDown) return
1741
+ shuttingDown = true
1742
+ info(`received ${signal}; shutting down`)
1743
+ clearTimeout(restartTimer)
1744
+ clearInterval(endpointTimer)
1745
+ clearInterval(watchdogTimer)
1746
+ clearInterval(funnelMonitorTimer)
1747
+ clearInterval(transportTimer)
1748
+ stopTunnel()
1749
+ if (relayClient) {
1750
+ try {
1751
+ relayClient.stop()
1752
+ } catch {}
1753
+ relayClient = null
1754
+ }
1755
+ if (utilityServer) {
1756
+ try {
1757
+ utilityServer.close()
1758
+ } catch {}
1759
+ utilityServer = null
1760
+ }
1761
+ if (mdnsChild) {
1762
+ try {
1763
+ mdnsChild.kill('SIGTERM')
1764
+ } catch {}
1765
+ mdnsChild = null
1766
+ }
1767
+ for (const socket of sockets) {
1768
+ try {
1769
+ socket.destroy()
1770
+ } catch {}
1771
+ }
1772
+ const finish = () => process.exit(0)
1773
+ if (server) {
1774
+ server.close(finish)
1775
+ } else {
1776
+ setTimeout(finish, 0)
1777
+ }
1778
+ if (child && child.exitCode === null) {
1779
+ try {
1780
+ child.kill('SIGTERM')
1781
+ } catch {}
1782
+ const killer = setTimeout(() => {
1783
+ try {
1784
+ child?.kill('SIGKILL')
1785
+ } catch {}
1786
+ finish()
1787
+ }, 3000)
1788
+ killer.unref()
1789
+ }
1790
+ setTimeout(finish, 4000).unref()
1791
+ }
1792
+
1793
+ let opts = {
1794
+ dir: process.cwd(),
1795
+ port: 4190,
1796
+ opencodePort: 4191,
1797
+ utilityPort: null,
1798
+ host: '0.0.0.0',
1799
+ publicUrl: null,
1800
+ tunnel: 'auto',
1801
+ ntfy: null,
1802
+ ntfyUrl: null,
1803
+ noAuth: false,
1804
+ mdns: true,
1805
+ printQr: true,
1806
+ opencodeBin: null,
1807
+ relay: null,
1808
+ deviceName: null,
1809
+ }
1810
+
1811
+ function abort(msg) {
1812
+ process.stderr.write(`${HELP}\n`)
1813
+ error(msg)
1814
+ process.exit(2)
1815
+ }
1816
+
1817
+ function readEndpointFile() {
1818
+ try {
1819
+ return JSON.parse(fs.readFileSync(path.join(configPaths().dir, 'endpoint.json'), 'utf8'))
1820
+ } catch {
1821
+ return null
1822
+ }
1823
+ }
1824
+
1825
+ function maskToken(token) {
1826
+ if (!token) return '(none)'
1827
+ const text = String(token)
1828
+ return text.length <= 8 ? '****' : `${text.slice(0, 4)}...${text.slice(-4)}`
1829
+ }
1830
+
1831
+ function execCommand(bin, args, timeout = 5000) {
1832
+ return new Promise((resolve) => {
1833
+ execFile(bin, args, { timeout }, (err, stdout, stderr) => {
1834
+ resolve({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') })
1835
+ })
1836
+ })
1837
+ }
1838
+
1839
+ async function probeUrl(url, headers = {}, timeout = 5000) {
1840
+ try {
1841
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeout) })
1842
+ const text = await res.text()
1843
+ return { ok: res.ok, status: res.status, text }
1844
+ } catch (err) {
1845
+ return { ok: false, status: 0, text: err.message }
1846
+ }
1847
+ }
1848
+
1849
+ function postLocal(port, path, timeout = 2500) {
1850
+ return new Promise((resolve) => {
1851
+ const req = http.request(
1852
+ { host: LOOPBACK, port, path, method: 'POST', timeout },
1853
+ (res) => {
1854
+ let text = ''
1855
+ res.setEncoding('utf8')
1856
+ res.on('data', (chunk) => {
1857
+ text += chunk
1858
+ })
1859
+ res.on('end', () => resolve({ status: res.statusCode, text }))
1860
+ }
1861
+ )
1862
+ req.on('timeout', () => req.destroy(new Error('timeout')))
1863
+ req.on('error', () => resolve(null))
1864
+ req.end()
1865
+ })
1866
+ }
1867
+
1868
+ function probeLocal(path, headers = {}, timeout = 3000) {
1869
+ return new Promise((resolve) => {
1870
+ const req = http.request(
1871
+ { host: LOOPBACK, port: opts.port, path, method: 'GET', headers, timeout },
1872
+ (res) => {
1873
+ let text = ''
1874
+ res.setEncoding('utf8')
1875
+ res.on('data', (chunk) => {
1876
+ text += chunk
1877
+ })
1878
+ res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text }))
1879
+ }
1880
+ )
1881
+ req.on('timeout', () => req.destroy(new Error('timeout')))
1882
+ req.on('error', (err) => resolve({ ok: false, status: 0, text: err.message }))
1883
+ req.end()
1884
+ })
1885
+ }
1886
+
1887
+ async function launchAgentState() {
1888
+ const result = await execCommand('launchctl', ['print', `gui/${process.getuid()}/${LAUNCH_LABEL}`], 5000)
1889
+ if (!result.ok) return 'not loaded'
1890
+ const match = /state = ([a-z]+)/.exec(result.stdout)
1891
+ return match ? match[1] : 'loaded'
1892
+ }
1893
+
1894
+ async function runSubcommand(name, argv) {
1895
+ const parsed = parseArgs(argv)
1896
+ const flags = parsed.error ? {} : parsed.flags
1897
+ const cfg = loadConfig()
1898
+ const port = flags.port !== undefined ? Number(flags.port) : Number(cfg.port ?? 4190)
1899
+ const token = flags.token !== undefined ? String(flags.token) : cfg.token
1900
+ opts.port = port
1901
+ const out = (line) => process.stdout.write(`${line}\n`)
1902
+ const authHeader = token ? { authorization: `Bearer ${token}` } : {}
1903
+
1904
+ if (name === 'funnel') {
1905
+ const action = (argv[0] || 'status').toLowerCase()
1906
+ opts.port = port
1907
+ if (action === 'off') {
1908
+ await disableFunnel()
1909
+ out('tailscale funnel disabled')
1910
+ return 0
1911
+ }
1912
+ if (action === 'status') {
1913
+ const active = await funnelUrlIfActive()
1914
+ out(active ? `funnel active: ${active}` : 'funnel not active')
1915
+ return active ? 0 : 1
1916
+ }
1917
+ if (action !== 'on') {
1918
+ out('usage: oc-remote funnel on|off|status')
1919
+ return 2
1920
+ }
1921
+ const enabled = await enableFunnel()
1922
+ if (enabled) {
1923
+ out(`funnel active: ${enabled}`)
1924
+ out('the companion will adopt it automatically (auto mode watches for it)')
1925
+ return 0
1926
+ }
1927
+ out('funnel not enabled yet: approve the link printed above, then run `oc-remote funnel on` again')
1928
+ return 1
1929
+ }
1930
+
1931
+ if (name === 'version') {
1932
+ out(VERSION)
1933
+ return 0
1934
+ }
1935
+
1936
+ if (name === 'pair') {
1937
+ const mainPort = Number(cfg.port ?? 4190)
1938
+ const candidates = []
1939
+ if (flags['utility-port'] !== undefined) candidates.push(Number(flags['utility-port']))
1940
+ if (cfg.utilityPort) candidates.push(Number(cfg.utilityPort))
1941
+ for (let offset = 1; offset <= 4; offset++) candidates.push(mainPort + offset)
1942
+ const ports = [...new Set(candidates)].filter((port) => port > 0 && port <= 65535)
1943
+ let payload = null
1944
+ for (const port of ports) {
1945
+ const res = await postLocal(port, '/_ocremote/invite')
1946
+ if (!res || res.status !== 200) continue
1947
+ try {
1948
+ payload = JSON.parse(res.text)
1949
+ break
1950
+ } catch {}
1951
+ }
1952
+ if (!payload) {
1953
+ error('could not get an invite from the running companion')
1954
+ out('hint: start it with `oc-remote --dir <project> --relay <wss://...>` or `bash companion/install.sh ...`')
1955
+ return 1
1956
+ }
1957
+ out(payload.url)
1958
+ if (payload.expiresAt) out(`expires: ${new Date(payload.expiresAt).toISOString()} (single use)`)
1959
+ if (!payload.relay) out('note: no relay configured; this is the legacy LAN/tunnel pairing link')
1960
+ if (qrcode) process.stdout.write(`${buildQr(payload.url).createASCII(1, 4)}\n`)
1961
+ return 0
1962
+ }
1963
+
1964
+ if (name === 'relay-status') {
1965
+ const relay = opts.relay ?? cfg.relay
1966
+ if (!relay) {
1967
+ out('relay: not configured (add --relay wss://relay.example)')
1968
+ return 1
1969
+ }
1970
+ const identityStore = new Identity(configPaths().dir).load()
1971
+ if (!identityStore) {
1972
+ out('identity: missing (start the companion once to create it)')
1973
+ return 1
1974
+ }
1975
+ out(`relay: ${relay}`)
1976
+ out(`device: ${identityStore.deviceID()} (${identityStore.name()})`)
1977
+ const pairs = identityStore.pairs()
1978
+ out(`paired devices: ${pairs.length}`)
1979
+ for (const item of pairs) out(` - ${item.pairID} ${item.clientID} ${item.name || ''}`)
1980
+ const httpBase = relay.replace(/^ws/, 'http').replace(/\/+$/, '')
1981
+ const probe = await probeUrl(`${httpBase}/v1/devices/${identityStore.deviceID()}`, {}, 5000)
1982
+ if (probe.ok) {
1983
+ try {
1984
+ const device = JSON.parse(probe.text)
1985
+ const seen = device.lastSeen ? new Date(device.lastSeen * 1000).toISOString() : 'never'
1986
+ out(`companion online: ${device.online} lastSeen: ${seen}`)
1987
+ } catch {}
1988
+ } else {
1989
+ out(`relay unreachable (HTTP ${probe.status})`)
1990
+ }
1991
+ return 0
1992
+ }
1993
+
1994
+ if (name === 'unpair') {
1995
+ const pairID = argv.find((value) => !value.startsWith('--'))
1996
+ if (!pairID) {
1997
+ out('usage: oc-remote unpair <pairID>')
1998
+ return 2
1999
+ }
2000
+ const identityStore = new Identity(configPaths().dir).load()
2001
+ const pair = identityStore ? identityStore.pair(pairID) : null
2002
+ if (!pair) {
2003
+ error(`unknown pair: ${pairID}`)
2004
+ return 1
2005
+ }
2006
+ const relay = opts.relay ?? cfg.relay
2007
+ if (relay) {
2008
+ try {
2009
+ const client = new RelayClient({ relayUrl: relay, identity: identityStore, adapter: null, logger: () => {} })
2010
+ await client.revokePair(pairID)
2011
+ out(`relay: revoked ${pairID}`)
2012
+ } catch (err) {
2013
+ error(`relay revoke failed: ${err.message}`)
2014
+ return 1
2015
+ }
2016
+ }
2017
+ identityStore.removePair(pairID)
2018
+ out(`local pair removed: ${pairID}`)
2019
+ return 0
2020
+ }
2021
+
2022
+ if (name === 'logs') {
2023
+ out(LOG_PATH)
2024
+ if (fs.existsSync(LOG_PATH)) {
2025
+ const lines = fs.readFileSync(LOG_PATH, 'utf8').trimEnd().split('\n')
2026
+ out(lines.slice(-40).join('\n'))
2027
+ } else {
2028
+ out('(no log file yet)')
2029
+ }
2030
+ return 0
2031
+ }
2032
+
2033
+ if (name === 'restart') {
2034
+ const result = await execCommand('launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${LAUNCH_LABEL}`], 8000)
2035
+ if (!result.ok) {
2036
+ error(`could not restart ${LAUNCH_LABEL}: ${result.stderr.trim() || 'agent not loaded'}`)
2037
+ out('hint: bash companion/install.sh --dir <project> --tunnel <mode>')
2038
+ return 1
2039
+ }
2040
+ out(`restarted ${LAUNCH_LABEL}`)
2041
+ return 0
2042
+ }
2043
+
2044
+ if (name === 'status') {
2045
+ const endpoint = readEndpointFile()
2046
+ const state = await launchAgentState()
2047
+ const health = await probeLocal('/_ocremote/health')
2048
+ const authenticated = token
2049
+ ? await probeLocal('/_ocremote/status', authHeader)
2050
+ : { ok: false, status: 0, text: 'no token' }
2051
+ out(`oc-remote v${VERSION}`)
2052
+ out(`config: ${configPaths().file}`)
2053
+ out(`token: ${maskToken(token)}`)
2054
+ out(`launch agent: ${state}`)
2055
+ out(`companion: ${health.ok ? `reachable on 127.0.0.1:${port}` : `NOT reachable on 127.0.0.1:${port}`}`)
2056
+ out(`auth: ${authenticated.ok ? 'token accepted' : `token rejected (HTTP ${authenticated.status})`}`)
2057
+ if (endpoint) {
2058
+ const age = Math.round((Date.now() - Number(endpoint.ts || 0)) / 1000)
2059
+ out(`endpoint: ${endpoint.url} [${endpoint.provider}] published ${age}s ago`)
2060
+ for (const entry of endpoint.urls || []) out(` - ${entry.url} [${entry.label}]`)
2061
+ out(`beacon id: ${endpoint.id}`)
2062
+ } else {
2063
+ out('endpoint: (no endpoint.json yet)')
2064
+ }
2065
+ return health.ok ? 0 : 1
2066
+ }
2067
+
2068
+ if (name === 'doctor') {
2069
+ out(`oc-remote doctor v${VERSION}`)
2070
+ let critical = 0
2071
+ const check = (ok, label, fix) => {
2072
+ out(`${ok ? ' ok ' : 'FAIL'} ${label}`)
2073
+ if (!ok && fix) out(` fix: ${fix}`)
2074
+ if (!ok) critical++
2075
+ }
2076
+ const note = (label, hint) => {
2077
+ out(` - ${label}`)
2078
+ if (hint) out(` hint: ${hint}`)
2079
+ }
2080
+
2081
+ const bin = locateBin(resolveOpencodeBin(flags.opencode))
2082
+ if (bin) {
2083
+ const version = await execCommand(bin, ['--version'], 8000)
2084
+ check(true, `opencode binary: ${bin}${version.ok ? ` (${version.stdout.trim()})` : ''}`)
2085
+ } else {
2086
+ check(false, 'opencode binary not found', 'install opencode or pass --opencode <path>')
2087
+ }
2088
+
2089
+ check(Boolean(token), `pairing token stored in ${configPaths().file}`, 'run `oc-remote` once to generate one')
2090
+
2091
+ const health = await probeLocal('/_ocremote/health')
2092
+ check(
2093
+ health.ok,
2094
+ `companion reachable on 127.0.0.1:${port}`,
2095
+ 'start it (`oc-remote --dir <project>`) or `bash companion/install.sh --dir <project>`'
2096
+ )
2097
+
2098
+ if (health.ok) {
2099
+ const status = await probeLocal('/_ocremote/status', authHeader)
2100
+ check(status.ok, `token accepted by the companion (HTTP ${status.status})`, 'rotate the token with `oc-remote --new-token`')
2101
+ const upstream = await probeLocal('/global/health', authHeader, 5000)
2102
+ check(upstream.ok, `opencode upstream healthy (HTTP ${upstream.status})`, 'restart with `oc-remote restart`')
2103
+ }
2104
+
2105
+ const state = await launchAgentState()
2106
+ if (state === 'running' || state === 'loaded') {
2107
+ check(true, `LaunchAgent ${LAUNCH_LABEL}: ${state}`)
2108
+ } else {
2109
+ note(
2110
+ `LaunchAgent ${LAUNCH_LABEL}: ${state}`,
2111
+ 'install it for always-on access: `bash companion/install.sh --dir <project> --tunnel auto`'
2112
+ )
2113
+ }
2114
+
2115
+ const lan = lanIPv4()
2116
+ if (lan.length) {
2117
+ check(true, `LAN address${lan.length === 1 ? '' : 'es'}: ${lan.map((e) => e.address).join(', ')}`)
2118
+ } else {
2119
+ note('no LAN address (Wi-Fi or Ethernet off?)', 'the phone can still connect through the tunnel')
2120
+ }
2121
+
2122
+ const tsInstalled = (await execCommand(tailscaleBin(), ['version'], 4000)).ok
2123
+ if (tsInstalled) {
2124
+ const running = await tailscaleRunning()
2125
+ const dnsName = running ? await tailscaleDnsName() : null
2126
+ if (running) {
2127
+ check(true, `tailscale: running (${dnsName || 'no DNS name'})`)
2128
+ if (dnsName) {
2129
+ const proxy = await funnelProxyTarget(dnsName)
2130
+ const active = funnelProxyMatches(proxy)
2131
+ if (active) {
2132
+ check(true, `tailscale funnel -> ${proxy}`)
2133
+ } else {
2134
+ note(
2135
+ 'tailscale funnel: not serving this companion',
2136
+ 'stable URL with a one-time approval: `oc-remote --tunnel funnel`'
2137
+ )
2138
+ }
2139
+ }
2140
+ } else {
2141
+ note('tailscale: stopped', 'open the Tailscale app for the stable Funnel URL / tailnet access')
2142
+ }
2143
+ } else {
2144
+ note('tailscale CLI not found', 'optional: stable Funnel URL / tailnet access')
2145
+ }
2146
+
2147
+ const endpoint = readEndpointFile()
2148
+ if (endpoint && endpoint.url) {
2149
+ const endpointProbe = await probeUrl(`${endpoint.url}/_ocremote/health`, {}, 8000)
2150
+ check(
2151
+ endpointProbe.ok,
2152
+ `endpoint reachable: ${endpoint.url} (HTTP ${endpointProbe.status})`,
2153
+ 'check the tunnel: `oc-remote logs` or restart with `oc-remote restart`'
2154
+ )
2155
+ } else {
2156
+ note('no endpoint published yet (LAN/tailscale only)', 'run the companion once to publish endpoint.json')
2157
+ }
2158
+
2159
+ if (opts.ntfy || cfg.ntfy) {
2160
+ const raw = String(cfg.ntfy || '')
2161
+ let base = 'https://ntfy.sh'
2162
+ if (/^https?:\/\//i.test(raw)) {
2163
+ try {
2164
+ base = new URL(raw).origin
2165
+ } catch {
2166
+ base = 'https://ntfy.sh'
2167
+ }
2168
+ }
2169
+ const ntfy = await probeUrl(`${base}/v1/health`, {}, 5000)
2170
+ check(ntfy.ok, `ntfy reachable (${base})`, 'check your network or use a self-hosted ntfy URL')
2171
+ } else {
2172
+ note('ntfy not configured', 'optional: push notifications + endpoint beacons')
2173
+ }
2174
+
2175
+ out(critical === 0 ? 'all checks passed' : `${critical} check${critical === 1 ? '' : 's'} failed`)
2176
+ return critical === 0 ? 0 : 1
2177
+ }
2178
+
2179
+ return 0
2180
+ }
2181
+
2182
+ async function main() {
2183
+ const argv = process.argv.slice(2)
2184
+ if (argv[0] && SUBCOMMANDS.has(argv[0])) {
2185
+ const code = await runSubcommand(argv[0], argv.slice(1))
2186
+ return code
2187
+ }
2188
+ const parsed = parseArgs(argv)
2189
+ if (parsed.error) abort(parsed.error)
2190
+ const flags = parsed.flags
2191
+ if (flags.help) {
2192
+ process.stdout.write(HELP)
2193
+ return 0
2194
+ }
2195
+
2196
+ const cfg = loadConfig()
2197
+
2198
+ opts.port = flags.port !== undefined ? Number(flags.port) : Number(cfg.port ?? 4190)
2199
+ opts.opencodePort =
2200
+ flags['opencode-port'] !== undefined ? Number(flags['opencode-port']) : Number(cfg.opencodePort ?? 4191)
2201
+ opts.host = flags.host !== undefined ? flags.host : (cfg.host ?? '0.0.0.0')
2202
+ opts.publicUrl = flags['public-url'] !== undefined ? flags['public-url'] : (cfg.publicUrl ?? null)
2203
+ opts.tunnel = flags.tunnel !== undefined ? String(flags.tunnel) : (cfg.tunnel ?? 'auto')
2204
+ opts.ntfy = flags.ntfy !== undefined ? flags.ntfy : (cfg.ntfy ?? null)
2205
+ opts.relay = flags.relay !== undefined ? String(flags.relay) : (cfg.relay ?? null)
2206
+ opts.deviceName = flags['device-name'] !== undefined ? String(flags['device-name']) : (cfg.deviceName ?? null)
2207
+ opts.utilityPort =
2208
+ flags['utility-port'] !== undefined
2209
+ ? Number(flags['utility-port'])
2210
+ : Number(cfg.utilityPort ?? 0) || null
2211
+ opts.noAuth = Boolean(flags['no-auth'])
2212
+ opts.mdns = !flags['no-mdns']
2213
+ opts.printQr = flags['print-qr'] !== false
2214
+
2215
+ if (!TUNNEL_MODES.has(opts.tunnel)) {
2216
+ abort(`invalid --tunnel: ${opts.tunnel} (expected ${[...TUNNEL_MODES].join(' | ')})`)
2217
+ }
2218
+
2219
+ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) abort(`invalid --port: ${flags.port ?? cfg.port}`)
2220
+ if (!Number.isInteger(opts.opencodePort) || opts.opencodePort < 1 || opts.opencodePort > 65535)
2221
+ abort(`invalid --opencode-port: ${flags['opencode-port'] ?? cfg.opencodePort}`)
2222
+ if (opts.port === opts.opencodePort) abort('--port and --opencode-port must differ')
2223
+ if (opts.utilityPort !== null && (!Number.isInteger(opts.utilityPort) || opts.utilityPort < 1 || opts.utilityPort > 65535))
2224
+ abort(`invalid --utility-port: ${flags['utility-port'] ?? cfg.utilityPort}`)
2225
+ if (opts.utilityPort !== null && (opts.utilityPort === opts.port || opts.utilityPort === opts.opencodePort))
2226
+ abort('--utility-port must differ from --port and --opencode-port')
2227
+ if (opts.relay && !/^wss?:\/\//i.test(opts.relay)) abort(`invalid --relay: ${opts.relay} (expected wss://...)`)
2228
+
2229
+ const dir = path.resolve(flags.dir !== undefined ? flags.dir : process.cwd())
2230
+ let stat
2231
+ try {
2232
+ stat = fs.statSync(dir)
2233
+ } catch {
2234
+ abort(`directory does not exist: ${dir}`)
2235
+ }
2236
+ if (!stat.isDirectory()) abort(`not a directory: ${dir}`)
2237
+ opts.dir = dir
2238
+
2239
+ if (flags.daemon) {
2240
+ const installer = path.join(HERE, 'install.sh')
2241
+ if (!fs.existsSync(installer)) abort(`install.sh not found next to oc-remote.mjs`)
2242
+ const args = [installer, '--dir', dir, '--port', String(opts.port)]
2243
+ if (opts.tunnel) args.push('--tunnel', opts.tunnel)
2244
+ if (opts.relay) args.push('--relay', opts.relay)
2245
+ if (opts.ntfy) args.push('--ntfy', String(opts.ntfy))
2246
+ if (opts.deviceName) args.push('--device-name', opts.deviceName)
2247
+ const child = spawn('bash', args, { stdio: 'inherit' })
2248
+ return await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0)))
2249
+ }
2250
+
2251
+ opts.opencodeBin = locateBin(resolveOpencodeBin(flags.opencode))
2252
+ if (!opts.opencodeBin) {
2253
+ if (flags.pair) {
2254
+ const say = (line) => process.stdout.write(`${line}\n`)
2255
+ error('opencode is not installed on this computer.')
2256
+ say(' 1. Install it: curl -fsSL https://opencode.ai/install | bash')
2257
+ say(' 2. Sign in: opencode auth login')
2258
+ say(' 3. Run again: npx ocremote --pair')
2259
+ return 1
2260
+ }
2261
+ abort(`opencode binary not found: ${flags.opencode || 'opencode'}`)
2262
+ }
2263
+
2264
+ if (flags.pair) {
2265
+ info('first run: starting opencode and preparing the pairing QR')
2266
+ info('keep this terminal open while you pair; for an always-on service use --daemon')
2267
+ }
2268
+
2269
+ if (opts.ntfy) {
2270
+ const topic = String(opts.ntfy)
2271
+ opts.ntfyUrl = /^https?:\/\//i.test(topic)
2272
+ ? topic
2273
+ : `https://ntfy.sh/${encodeURIComponent(topic)}`
2274
+ opts.ntfyTarget = ntfyTarget(opts.ntfy)
2275
+ }
2276
+
2277
+ const storedToken = typeof cfg.token === 'string' && cfg.token.length ? cfg.token : null
2278
+ let token
2279
+ let tokenChanged = false
2280
+ if (flags['new-token']) {
2281
+ token = randomHex(16)
2282
+ tokenChanged = true
2283
+ info('rotating pairing token (--new-token)')
2284
+ } else if (flags.token !== undefined) {
2285
+ token = String(flags.token)
2286
+ tokenChanged = token !== storedToken
2287
+ } else if (storedToken) {
2288
+ token = storedToken
2289
+ } else {
2290
+ token = randomHex(16)
2291
+ tokenChanged = true
2292
+ info('generated a new pairing token')
2293
+ }
2294
+ remoteToken = token
2295
+
2296
+ const nextConfig = {
2297
+ version: 1,
2298
+ token,
2299
+ port: opts.port,
2300
+ opencodePort: opts.opencodePort,
2301
+ host: opts.host,
2302
+ publicUrl: opts.publicUrl,
2303
+ tunnel: opts.tunnel,
2304
+ ntfy: opts.ntfy,
2305
+ relay: opts.relay,
2306
+ deviceName: opts.deviceName,
2307
+ utilityPort: flags['utility-port'] !== undefined ? opts.utilityPort : (cfg.utilityPort ?? null),
2308
+ updatedAt: new Date().toISOString(),
2309
+ }
2310
+ const configChanged =
2311
+ tokenChanged ||
2312
+ cfg.port !== nextConfig.port ||
2313
+ cfg.opencodePort !== nextConfig.opencodePort ||
2314
+ cfg.host !== nextConfig.host ||
2315
+ cfg.publicUrl !== nextConfig.publicUrl ||
2316
+ cfg.tunnel !== nextConfig.tunnel ||
2317
+ cfg.ntfy !== nextConfig.ntfy ||
2318
+ cfg.relay !== nextConfig.relay ||
2319
+ cfg.deviceName !== nextConfig.deviceName ||
2320
+ cfg.utilityPort !== nextConfig.utilityPort
2321
+ if (configChanged) {
2322
+ try {
2323
+ saveConfig(nextConfig)
2324
+ info(`config saved: ${configPaths().file} (0600)`)
2325
+ } catch (err) {
2326
+ warn(`could not save config: ${err.message}`)
2327
+ }
2328
+ }
2329
+
2330
+ internalPassword = randomHex(16)
2331
+ internalAuth = `Basic ${Buffer.from(`opencode:${internalPassword}`, 'utf8').toString('base64')}`
2332
+
2333
+ if (!(await portFree(opts.opencodePort))) {
2334
+ error(`port ${opts.opencodePort} is already in use; pick another with --opencode-port`)
2335
+ return 1
2336
+ }
2337
+
2338
+ await computeUrls()
2339
+
2340
+ launchOpencode()
2341
+ const health = await waitHealthy(30000)
2342
+ if (!health) {
2343
+ error('opencode did not become healthy within 30s; aborting')
2344
+ if (child && child.exitCode === null) child.kill('SIGKILL')
2345
+ return 1
2346
+ }
2347
+ info(`opencode is healthy (v${health.version})`)
2348
+
2349
+ server = createProxyServer()
2350
+ await new Promise((resolve, reject) => {
2351
+ server.once('error', reject)
2352
+ server.listen(opts.port, opts.host, () => {
2353
+ server.off('error', reject)
2354
+ resolve()
2355
+ })
2356
+ }).catch((err) => {
2357
+ error(
2358
+ err.code === 'EADDRINUSE'
2359
+ ? `port ${opts.port} is already in use; pick another with --port`
2360
+ : `could not listen on ${opts.host}:${opts.port}: ${err.message}`
2361
+ )
2362
+ if (child && child.exitCode === null) child.kill('SIGKILL')
2363
+ return 1
2364
+ })
2365
+
2366
+ printBanner(health)
2367
+ utilityServer = await createUtilityServer()
2368
+ startMdns()
2369
+ startNtfy()
2370
+ startEndpointBeacon()
2371
+ startTransportWatcher()
2372
+ startWatchdog()
2373
+ await startTunnel()
2374
+ await startRelay()
2375
+ return 0
2376
+ }
2377
+
2378
+ process.on('SIGINT', () => shutdown('SIGINT'))
2379
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
2380
+ process.on('uncaughtException', (err) => error(`uncaught exception: ${err.stack || err.message}`))
2381
+ process.on('unhandledRejection', (err) => error(`unhandled rejection: ${err?.stack || err}`))
2382
+
2383
+ main()
2384
+ .then((code) => {
2385
+ if (code !== 0) process.exit(code)
2386
+ })
2387
+ .catch((err) => {
2388
+ error(`fatal: ${err.stack || err.message}`)
2389
+ process.exit(1)
2390
+ })