ralph-ui 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -0
  3. package/bin/ralph-ui.js +1115 -0
  4. package/package.json +130 -0
@@ -0,0 +1,1115 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Ralph UI - NPX wrapper
5
+ *
6
+ * Downloads and runs the Ralph UI server binary for the current platform.
7
+ * Features:
8
+ * - Auto-update checking and updates
9
+ * - First-time setup wizard
10
+ * - Configuration persistence
11
+ * - Progress bar downloads
12
+ */
13
+
14
+ import { execFileSync, spawn } from 'child_process'
15
+ import {
16
+ createWriteStream,
17
+ existsSync,
18
+ mkdirSync,
19
+ chmodSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ unlinkSync,
23
+ } from 'fs'
24
+ import { dirname, join } from 'path'
25
+ import { fileURLToPath } from 'url'
26
+ import https from 'https'
27
+ import readline from 'readline'
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ const GITHUB_REPO = 'dario-valles/Ralph-UI'
31
+ const DEFAULT_CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 24 hours
32
+
33
+ // ANSI color codes
34
+ const colors = {
35
+ reset: '\x1b[0m',
36
+ bold: '\x1b[1m',
37
+ dim: '\x1b[2m',
38
+ red: '\x1b[31m',
39
+ green: '\x1b[32m',
40
+ yellow: '\x1b[33m',
41
+ blue: '\x1b[34m',
42
+ magenta: '\x1b[35m',
43
+ cyan: '\x1b[36m',
44
+ white: '\x1b[37m',
45
+ bgBlue: '\x1b[44m',
46
+ }
47
+
48
+ const c = {
49
+ success: (text) => `${colors.green}${text}${colors.reset}`,
50
+ error: (text) => `${colors.red}${text}${colors.reset}`,
51
+ warn: (text) => `${colors.yellow}${text}${colors.reset}`,
52
+ info: (text) => `${colors.cyan}${text}${colors.reset}`,
53
+ bold: (text) => `${colors.bold}${text}${colors.reset}`,
54
+ dim: (text) => `${colors.dim}${text}${colors.reset}`,
55
+ }
56
+
57
+ // Platform mappings
58
+ const PLATFORM_MAP = {
59
+ darwin: 'apple-darwin',
60
+ linux: 'unknown-linux-gnu',
61
+ win32: 'pc-windows-msvc',
62
+ }
63
+
64
+ const ARCH_MAP = {
65
+ x64: 'x86_64',
66
+ arm64: 'aarch64',
67
+ }
68
+
69
+ const PLATFORM_DISPLAY = {
70
+ darwin: 'macOS',
71
+ linux: 'Linux',
72
+ win32: 'Windows',
73
+ }
74
+
75
+ const ARCH_DISPLAY = {
76
+ x64: 'x64',
77
+ arm64: 'Apple Silicon',
78
+ }
79
+
80
+ // ============================================================================
81
+ // Path helpers
82
+ // ============================================================================
83
+
84
+ function getHome() {
85
+ return process.env.HOME || process.env.USERPROFILE
86
+ }
87
+
88
+ function getRalphDir() {
89
+ return join(getHome(), '.ralph-ui')
90
+ }
91
+
92
+ function getCacheDir() {
93
+ return join(getRalphDir(), 'bin')
94
+ }
95
+
96
+ function getConfigPath() {
97
+ return join(getRalphDir(), 'config.json')
98
+ }
99
+
100
+ function getVersionPath() {
101
+ return join(getRalphDir(), 'version.txt')
102
+ }
103
+
104
+ function getBinaryName() {
105
+ return process.platform === 'win32' ? 'ralph-ui.exe' : 'ralph-ui'
106
+ }
107
+
108
+ function getBinaryPath() {
109
+ return join(getCacheDir(), getBinaryName())
110
+ }
111
+
112
+ // ============================================================================
113
+ // Platform detection
114
+ // ============================================================================
115
+
116
+ function getPlatformTarget() {
117
+ const platform = PLATFORM_MAP[process.platform]
118
+ const arch = ARCH_MAP[process.arch]
119
+
120
+ if (!platform || !arch) {
121
+ console.error(c.error(`Unsupported platform: ${process.platform}-${process.arch}`))
122
+ console.error(
123
+ 'Supported platforms: darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64'
124
+ )
125
+ process.exit(1)
126
+ }
127
+
128
+ return `${arch}-${platform}`
129
+ }
130
+
131
+ function getPlatformDisplayName() {
132
+ const platform = PLATFORM_DISPLAY[process.platform] || process.platform
133
+ const arch = ARCH_DISPLAY[process.arch] || process.arch
134
+ return `${platform} (${arch})`
135
+ }
136
+
137
+ // ============================================================================
138
+ // Configuration management
139
+ // ============================================================================
140
+
141
+ function getDefaultConfig() {
142
+ return {
143
+ autoUpdate: true,
144
+ updateCheckInterval: DEFAULT_CHECK_INTERVAL,
145
+ lastUpdateCheck: 0,
146
+ notifyOnly: false,
147
+ accessMode: 'local',
148
+ tunnel: {
149
+ provider: null,
150
+ configured: false,
151
+ },
152
+ server: {
153
+ port: 3420,
154
+ token: null,
155
+ },
156
+ setupCompleted: false,
157
+ }
158
+ }
159
+
160
+ function loadConfig() {
161
+ const configPath = getConfigPath()
162
+ if (!existsSync(configPath)) {
163
+ return getDefaultConfig()
164
+ }
165
+ try {
166
+ const content = readFileSync(configPath, 'utf-8')
167
+ return { ...getDefaultConfig(), ...JSON.parse(content) }
168
+ } catch {
169
+ return getDefaultConfig()
170
+ }
171
+ }
172
+
173
+ function saveConfig(config) {
174
+ const configPath = getConfigPath()
175
+ mkdirSync(dirname(configPath), { recursive: true })
176
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
177
+ }
178
+
179
+ function getCurrentVersion() {
180
+ const versionPath = getVersionPath()
181
+ if (!existsSync(versionPath)) {
182
+ return null
183
+ }
184
+ try {
185
+ return readFileSync(versionPath, 'utf-8').trim()
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+
191
+ function saveVersion(version) {
192
+ const versionPath = getVersionPath()
193
+ mkdirSync(dirname(versionPath), { recursive: true })
194
+ writeFileSync(versionPath, version + '\n')
195
+ }
196
+
197
+ // ============================================================================
198
+ // Version comparison
199
+ // ============================================================================
200
+
201
+ function parseVersion(version) {
202
+ const match = version.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/)
203
+ if (!match) return null
204
+ return {
205
+ major: parseInt(match[1], 10),
206
+ minor: parseInt(match[2], 10),
207
+ patch: parseInt(match[3], 10),
208
+ }
209
+ }
210
+
211
+ function isNewerVersion(current, latest) {
212
+ const cur = parseVersion(current)
213
+ const lat = parseVersion(latest)
214
+ if (!cur || !lat) return false
215
+
216
+ if (lat.major > cur.major) return true
217
+ if (lat.major < cur.major) return false
218
+ if (lat.minor > cur.minor) return true
219
+ if (lat.minor < cur.minor) return false
220
+ return lat.patch > cur.patch
221
+ }
222
+
223
+ // ============================================================================
224
+ // GitHub API
225
+ // ============================================================================
226
+
227
+ async function getLatestRelease() {
228
+ return new Promise((resolve, reject) => {
229
+ const options = {
230
+ hostname: 'api.github.com',
231
+ path: `/repos/${GITHUB_REPO}/releases/latest`,
232
+ headers: { 'User-Agent': 'ralph-ui-npm' },
233
+ }
234
+
235
+ https
236
+ .get(options, (response) => {
237
+ if (response.statusCode === 404) {
238
+ reject(new Error('No releases found'))
239
+ return
240
+ }
241
+ if (response.statusCode !== 200) {
242
+ reject(new Error(`GitHub API error: ${response.statusCode}`))
243
+ return
244
+ }
245
+ let data = ''
246
+ response.on('data', (chunk) => (data += chunk))
247
+ response.on('end', () => {
248
+ try {
249
+ const release = JSON.parse(data)
250
+ resolve(release)
251
+ } catch (e) {
252
+ reject(new Error('Failed to parse release info'))
253
+ }
254
+ })
255
+ })
256
+ .on('error', reject)
257
+ })
258
+ }
259
+
260
+ // ============================================================================
261
+ // Download with progress
262
+ // ============================================================================
263
+
264
+ function createProgressBar(total) {
265
+ const width = 40
266
+ let current = 0
267
+
268
+ return {
269
+ update(bytes) {
270
+ current = bytes
271
+ const percent = Math.min(100, Math.round((current / total) * 100))
272
+ const filled = Math.round((percent / 100) * width)
273
+ const empty = width - filled
274
+ const bar = '█'.repeat(filled) + '░'.repeat(empty)
275
+ process.stdout.write(`\r ${bar} ${percent}%`)
276
+ },
277
+ finish() {
278
+ process.stdout.write('\n')
279
+ },
280
+ }
281
+ }
282
+
283
+ async function downloadFileWithProgress(url, destPath, showProgress = true) {
284
+ return new Promise((resolve, reject) => {
285
+ const request = https.get(url, (response) => {
286
+ // Handle redirects
287
+ if (response.statusCode === 302 || response.statusCode === 301) {
288
+ downloadFileWithProgress(response.headers.location, destPath, showProgress)
289
+ .then(resolve)
290
+ .catch(reject)
291
+ return
292
+ }
293
+
294
+ if (response.statusCode !== 200) {
295
+ reject(new Error(`Failed to download: ${response.statusCode}`))
296
+ return
297
+ }
298
+
299
+ const totalSize = parseInt(response.headers['content-length'], 10) || 0
300
+ let downloadedSize = 0
301
+ const progress = showProgress && totalSize > 0 ? createProgressBar(totalSize) : null
302
+
303
+ const file = createWriteStream(destPath)
304
+
305
+ response.on('data', (chunk) => {
306
+ downloadedSize += chunk.length
307
+ if (progress) {
308
+ progress.update(downloadedSize)
309
+ }
310
+ })
311
+
312
+ response.pipe(file)
313
+
314
+ file.on('finish', () => {
315
+ if (progress) {
316
+ progress.finish()
317
+ }
318
+ file.close()
319
+ resolve()
320
+ })
321
+
322
+ file.on('error', (err) => {
323
+ if (progress) {
324
+ progress.finish()
325
+ }
326
+ reject(err)
327
+ })
328
+ })
329
+
330
+ request.on('error', reject)
331
+ })
332
+ }
333
+
334
+ // ============================================================================
335
+ // Binary download and installation
336
+ // ============================================================================
337
+
338
+ async function downloadBinary(version = null, showWelcome = false) {
339
+ const cacheDir = getCacheDir()
340
+ const binaryPath = getBinaryPath()
341
+
342
+ // Create cache directory
343
+ mkdirSync(cacheDir, { recursive: true })
344
+
345
+ try {
346
+ const release = await getLatestRelease()
347
+ const releaseVersion = release.tag_name.replace(/^v/, '')
348
+ const displayVersion = version || releaseVersion
349
+
350
+ const target = getPlatformTarget()
351
+ const assetName = `ralph-ui-${target}.tar.gz`
352
+
353
+ const asset = release.assets.find((a) => a.name === assetName)
354
+ if (!asset) {
355
+ console.error(c.error(`No binary found for platform: ${target}`))
356
+ console.error('Available assets:', release.assets.map((a) => a.name).join(', '))
357
+ process.exit(1)
358
+ }
359
+
360
+ if (showWelcome) {
361
+ console.log(
362
+ `\n Downloading Ralph UI ${c.info('v' + displayVersion)} for ${getPlatformDisplayName()}...`
363
+ )
364
+ } else {
365
+ console.log(`Downloading Ralph UI v${displayVersion}...`)
366
+ }
367
+
368
+ // Download and extract
369
+ const tarPath = join(cacheDir, assetName)
370
+ await downloadFileWithProgress(asset.browser_download_url, tarPath)
371
+
372
+ // Remove old binary if exists
373
+ if (existsSync(binaryPath)) {
374
+ unlinkSync(binaryPath)
375
+ }
376
+
377
+ // Extract using tar (available on all platforms with Node.js)
378
+ execFileSync('tar', ['-xzf', tarPath, '-C', cacheDir], { stdio: 'pipe' })
379
+
380
+ // Clean up tar file
381
+ unlinkSync(tarPath)
382
+
383
+ // Make executable on Unix
384
+ if (process.platform !== 'win32') {
385
+ chmodSync(binaryPath, 0o755)
386
+ }
387
+
388
+ // Save version
389
+ saveVersion(releaseVersion)
390
+
391
+ if (showWelcome) {
392
+ console.log(`\n ${c.success('✓')} Binary installed to ${c.dim('~/.ralph-ui/bin/ralph-ui')}`)
393
+ } else {
394
+ console.log(c.success('✓ Downloaded successfully!'))
395
+ }
396
+
397
+ return { binaryPath, version: releaseVersion }
398
+ } catch (error) {
399
+ console.error(c.error('Failed to download binary:'), error.message)
400
+ console.error('\nYou can build from source instead:')
401
+ console.error(' git clone https://github.com/dario-valles/Ralph-UI.git')
402
+ console.error(' cd Ralph-UI && bun install && bun run server:build')
403
+ process.exit(1)
404
+ }
405
+ }
406
+
407
+ // ============================================================================
408
+ // Update checking
409
+ // ============================================================================
410
+
411
+ async function checkForUpdates(config, flags) {
412
+ if (flags.offline || flags.skipUpdate) {
413
+ return { hasUpdate: false }
414
+ }
415
+
416
+ const currentVersion = getCurrentVersion()
417
+ if (!currentVersion) {
418
+ return { hasUpdate: false }
419
+ }
420
+
421
+ // Check if we should skip based on interval
422
+ const now = Date.now()
423
+ const interval = config.updateCheckInterval || DEFAULT_CHECK_INTERVAL
424
+ if (!flags.forceUpdate && config.lastUpdateCheck && now - config.lastUpdateCheck < interval) {
425
+ return { hasUpdate: false }
426
+ }
427
+
428
+ try {
429
+ const release = await getLatestRelease()
430
+ const latestVersion = release.tag_name.replace(/^v/, '')
431
+
432
+ // Update last check time
433
+ config.lastUpdateCheck = now
434
+ saveConfig(config)
435
+
436
+ if (isNewerVersion(currentVersion, latestVersion)) {
437
+ return { hasUpdate: true, currentVersion, latestVersion }
438
+ }
439
+
440
+ return { hasUpdate: false }
441
+ } catch (error) {
442
+ // Silently fail on network errors
443
+ return { hasUpdate: false, error: error.message }
444
+ }
445
+ }
446
+
447
+ function showUpdateBanner(currentVersion, latestVersion) {
448
+ console.log()
449
+ console.log(`╔${'═'.repeat(61)}╗`)
450
+ console.log(
451
+ `║ ${c.warn('Update available:')} v${currentVersion} → ${c.success('v' + latestVersion)}${' '.repeat(
452
+ 35 - currentVersion.length - latestVersion.length
453
+ )}║`
454
+ )
455
+ console.log(
456
+ `║ Run with ${c.info('--update')} to upgrade, or ${c.dim('--skip-update')} to ignore ║`
457
+ )
458
+ console.log(`╚${'═'.repeat(61)}╝`)
459
+ console.log()
460
+ }
461
+
462
+ async function performUpdate(currentVersion, latestVersion) {
463
+ console.log(
464
+ `\nUpdating Ralph UI ${c.dim('v' + currentVersion)} → ${c.success('v' + latestVersion)}...`
465
+ )
466
+ await downloadBinary(latestVersion, false)
467
+ console.log(c.success('✓ Updated successfully!\n'))
468
+ }
469
+
470
+ // ============================================================================
471
+ // Interactive prompts
472
+ // ============================================================================
473
+
474
+ function createPrompt() {
475
+ const rl = readline.createInterface({
476
+ input: process.stdin,
477
+ output: process.stdout,
478
+ })
479
+
480
+ return {
481
+ async question(query) {
482
+ return new Promise((resolve) => {
483
+ rl.question(query, resolve)
484
+ })
485
+ },
486
+ async select(prompt, options) {
487
+ console.log(`\n${prompt}\n`)
488
+ options.forEach((opt, i) => {
489
+ console.log(` ${c.info((i + 1).toString())}. ${opt.label}`)
490
+ if (opt.description) {
491
+ console.log(` ${c.dim(opt.description)}`)
492
+ }
493
+ })
494
+ console.log()
495
+
496
+ while (true) {
497
+ const answer = await this.question(`${c.dim('>')} `)
498
+ const num = parseInt(answer, 10)
499
+ if (num >= 1 && num <= options.length) {
500
+ return options[num - 1].value
501
+ }
502
+ console.log(c.warn('Please enter a valid number.'))
503
+ }
504
+ },
505
+ async confirm(prompt, defaultYes = true) {
506
+ const hint = defaultYes ? '[Y/n]' : '[y/N]'
507
+ const answer = await this.question(`${prompt} ${c.dim(hint)} `)
508
+ if (!answer.trim()) return defaultYes
509
+ return answer.toLowerCase().startsWith('y')
510
+ },
511
+ async password(prompt) {
512
+ // Simple password input (characters will be visible - Node.js limitation without extra deps)
513
+ const answer = await this.question(`${prompt} `)
514
+ return answer.trim()
515
+ },
516
+ async waitForKey(prompt) {
517
+ const answer = await this.question(`${prompt} `)
518
+ return answer.toLowerCase()
519
+ },
520
+ close() {
521
+ rl.close()
522
+ },
523
+ }
524
+ }
525
+
526
+ // ============================================================================
527
+ // Tailscale helpers
528
+ // ============================================================================
529
+
530
+ async function checkTailscaleAvailable() {
531
+ try {
532
+ execFileSync('tailscale', ['version'], { stdio: 'pipe' })
533
+ return true
534
+ } catch {
535
+ return false
536
+ }
537
+ }
538
+
539
+ async function getTailscaleStatus() {
540
+ try {
541
+ const output = execFileSync('tailscale', ['status', '--json'], {
542
+ stdio: ['pipe', 'pipe', 'pipe'],
543
+ encoding: 'utf-8',
544
+ })
545
+ const status = JSON.parse(output)
546
+
547
+ // Get the device's tailscale hostname
548
+ const selfKey = status.Self?.DNSName || ''
549
+ // Remove trailing dot from DNS name
550
+ const hostname = selfKey.replace(/\.$/, '')
551
+
552
+ return {
553
+ connected: status.BackendState === 'Running',
554
+ hostname: hostname || null,
555
+ }
556
+ } catch {
557
+ return { connected: false, hostname: null }
558
+ }
559
+ }
560
+
561
+ async function setupTailscaleServe(port) {
562
+ try {
563
+ // First, reset any existing serve config
564
+ try {
565
+ execFileSync('tailscale', ['serve', 'reset'], { stdio: 'pipe' })
566
+ } catch {
567
+ // Ignore errors from reset
568
+ }
569
+
570
+ // Configure serve to forward to localhost:port
571
+ execFileSync('tailscale', ['serve', '--bg', `localhost:${port}`], {
572
+ stdio: 'pipe',
573
+ })
574
+
575
+ return true
576
+ } catch (error) {
577
+ console.error(c.dim(`Tailscale serve error: ${error.message}`))
578
+ return false
579
+ }
580
+ }
581
+
582
+ async function getTailscaleServeUrl() {
583
+ try {
584
+ const output = execFileSync('tailscale', ['serve', 'status'], {
585
+ stdio: ['pipe', 'pipe', 'pipe'],
586
+ encoding: 'utf-8',
587
+ })
588
+
589
+ // Parse the output to find the HTTPS URL (including port if present)
590
+ // Example output: "https://mac.tail18652a.ts.net:80 (tailnet only)"
591
+ const match = output.match(/https:\/\/([^\s]+)/)
592
+ if (match) {
593
+ return `https://${match[1]}`
594
+ }
595
+ return null
596
+ } catch {
597
+ return null
598
+ }
599
+ }
600
+
601
+ // ============================================================================
602
+ // Setup wizard
603
+ // ============================================================================
604
+
605
+ async function runSetupWizard(config, isFirstRun = false, downloadedVersion = null) {
606
+ const prompt = createPrompt()
607
+
608
+ try {
609
+ console.log()
610
+ console.log('━'.repeat(63))
611
+ console.log()
612
+ console.log(`${c.info('📱')} ${c.bold('Remote Access Setup')} (optional)`)
613
+ console.log()
614
+ console.log('Ralph UI can be accessed from your phone, tablet, or any device.')
615
+ console.log('For push notifications to work, you need HTTPS access.')
616
+
617
+ const accessMode = await prompt.select('How would you like to access Ralph UI?', [
618
+ { value: 'local', label: 'Local only', description: 'http://localhost:3420' },
619
+ { value: 'network', label: 'Local network', description: 'http://<your-ip>:3420' },
620
+ {
621
+ value: 'tunnel',
622
+ label: 'Remote via tunnel',
623
+ description: 'HTTPS - enables push notifications',
624
+ },
625
+ { value: 'skip', label: 'Skip for now' },
626
+ ])
627
+
628
+ config.accessMode = accessMode === 'skip' ? 'local' : accessMode
629
+
630
+ if (accessMode === 'tunnel') {
631
+ console.log()
632
+ console.log('━'.repeat(63))
633
+ console.log()
634
+ console.log(`${c.info('🔐')} ${c.bold('Tunnel Setup')}`)
635
+
636
+ const provider = await prompt.select('Choose your tunnel provider:', [
637
+ {
638
+ value: 'cloudflare',
639
+ label: 'Cloudflare Tunnel (recommended)',
640
+ description: 'Free, reliable, custom domains',
641
+ },
642
+ { value: 'ngrok', label: 'ngrok', description: 'Quick setup, random URLs on free tier' },
643
+ {
644
+ value: 'tailscale',
645
+ label: 'Tailscale Funnel',
646
+ description: 'If you already use Tailscale',
647
+ },
648
+ { value: 'manual', label: "I'll configure manually" },
649
+ ])
650
+
651
+ config.tunnel.provider = provider
652
+
653
+ if (provider !== 'manual') {
654
+ console.log()
655
+ console.log('━'.repeat(63))
656
+ console.log()
657
+
658
+ if (provider === 'cloudflare') {
659
+ console.log(`${c.info('☁️')} ${c.bold('Cloudflare Tunnel Setup')}`)
660
+ console.log()
661
+ console.log('1. Install cloudflared:')
662
+ console.log(` ${c.info('brew install cloudflared')} # macOS`)
663
+ console.log(
664
+ ` ${c.dim('# or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/')}`
665
+ )
666
+ console.log()
667
+ console.log('2. Login to Cloudflare:')
668
+ console.log(` ${c.info('cloudflared tunnel login')}`)
669
+ console.log()
670
+ console.log('3. Create a tunnel:')
671
+ console.log(` ${c.info('cloudflared tunnel create ralph-ui')}`)
672
+ console.log()
673
+ console.log('4. Start the tunnel (run this in a separate terminal):')
674
+ console.log(` ${c.info('cloudflared tunnel --url http://localhost:3420')}`)
675
+ console.log()
676
+ console.log('Your HTTPS URL will be shown in the cloudflared output.')
677
+ } else if (provider === 'ngrok') {
678
+ console.log(`${c.info('🔗')} ${c.bold('ngrok Setup')}`)
679
+ console.log()
680
+ console.log('1. Install ngrok:')
681
+ console.log(` ${c.info('brew install ngrok')} # macOS`)
682
+ console.log(` ${c.dim('# or: https://ngrok.com/download')}`)
683
+ console.log()
684
+ console.log('2. Start ngrok (run in a separate terminal):')
685
+ console.log(` ${c.info('ngrok http 3420')}`)
686
+ console.log()
687
+ console.log('Your HTTPS URL will be shown in the ngrok output.')
688
+ } else if (provider === 'tailscale') {
689
+ console.log(`${c.info('🔒')} ${c.bold('Tailscale Serve Setup')}`)
690
+ console.log()
691
+
692
+ // Check if tailscale CLI is available
693
+ const tailscaleAvailable = await checkTailscaleAvailable()
694
+
695
+ if (tailscaleAvailable) {
696
+ // Get tailscale status to check if connected
697
+ const tailscaleStatus = await getTailscaleStatus()
698
+
699
+ if (tailscaleStatus.connected) {
700
+ console.log(`${c.success('✓')} Tailscale is connected as ${c.info(tailscaleStatus.hostname)}`)
701
+ console.log()
702
+
703
+ const autoSetup = await prompt.confirm(
704
+ 'Would you like to auto-configure Tailscale serve?',
705
+ true
706
+ )
707
+
708
+ if (autoSetup) {
709
+ const port = config.server.port || 3420
710
+ const success = await setupTailscaleServe(port)
711
+
712
+ if (success) {
713
+ console.log()
714
+ console.log(`${c.success('✓')} Tailscale serve configured!`)
715
+ console.log()
716
+ console.log(` Your HTTPS URL: ${c.info(`https://${tailscaleStatus.hostname}`)}`)
717
+ config.tunnel.configured = true
718
+ config.tunnel.hostname = tailscaleStatus.hostname
719
+ } else {
720
+ console.log()
721
+ console.log(c.warn('Could not auto-configure. Manual steps:'))
722
+ console.log(` ${c.info(`tailscale serve --bg localhost:${port}`)}`)
723
+ }
724
+ } else {
725
+ const port = config.server.port || 3420
726
+ console.log()
727
+ console.log('To configure manually, run:')
728
+ console.log(` ${c.info(`tailscale serve --bg localhost:${port}`)}`)
729
+ console.log()
730
+ console.log(`Your HTTPS URL will be: ${c.info(`https://${tailscaleStatus.hostname}`)}`)
731
+ }
732
+ } else {
733
+ console.log(c.warn('Tailscale is installed but not connected.'))
734
+ console.log()
735
+ console.log('1. Connect to Tailscale:')
736
+ console.log(` ${c.info('tailscale up')}`)
737
+ console.log()
738
+ console.log('2. Then configure serve:')
739
+ console.log(` ${c.info('tailscale serve --bg localhost:3420')}`)
740
+ }
741
+ } else {
742
+ console.log('1. Install Tailscale:')
743
+ console.log(` ${c.info('https://tailscale.com/download')}`)
744
+ console.log()
745
+ console.log('2. Connect to your tailnet:')
746
+ console.log(` ${c.info('tailscale up')}`)
747
+ console.log()
748
+ console.log('3. Configure serve:')
749
+ console.log(` ${c.info('tailscale serve --bg localhost:3420')}`)
750
+ console.log()
751
+ console.log('Your HTTPS URL will be your-device.ts.net')
752
+ }
753
+ }
754
+
755
+ if (provider !== 'tailscale' || !config.tunnel.configured) {
756
+ console.log()
757
+ const key = await prompt.waitForKey(`Press Enter when ready, or 's' to skip...`)
758
+ if (key !== 's') {
759
+ config.tunnel.configured = true
760
+ }
761
+ }
762
+ }
763
+ }
764
+
765
+ // Token setup
766
+ console.log()
767
+ console.log('━'.repeat(63))
768
+ console.log()
769
+ console.log(`${c.info('🔑')} ${c.bold('Auth Token')}`)
770
+
771
+ const tokenChoice = await prompt.select('Choose token preference:', [
772
+ { value: 'random', label: 'Generate random token', description: 'New token each restart' },
773
+ { value: 'fixed', label: 'Set a fixed token', description: 'Persistent across restarts' },
774
+ { value: 'env', label: 'Use environment variable', description: 'RALPH_SERVER_TOKEN' },
775
+ ])
776
+
777
+ if (tokenChoice === 'fixed') {
778
+ const token = await prompt.password('Enter your token:')
779
+ if (token) {
780
+ config.server.token = token
781
+ }
782
+ } else if (tokenChoice === 'env') {
783
+ config.server.token = 'env'
784
+ } else {
785
+ config.server.token = null
786
+ }
787
+
788
+ // Auto-update setup
789
+ console.log()
790
+ console.log('━'.repeat(63))
791
+ console.log()
792
+ console.log(`${c.info('🔄')} ${c.bold('Auto-Update')}`)
793
+
794
+ const updateChoice = await prompt.select('Check for updates automatically?', [
795
+ { value: 'auto', label: 'Yes, update automatically (recommended)' },
796
+ { value: 'notify', label: "Yes, notify me but don't auto-update" },
797
+ { value: 'manual', label: "No, I'll update manually" },
798
+ ])
799
+
800
+ config.autoUpdate = updateChoice === 'auto'
801
+ config.notifyOnly = updateChoice === 'notify'
802
+
803
+ // Save configuration
804
+ config.setupCompleted = true
805
+ saveConfig(config)
806
+
807
+ // Show completion
808
+ console.log()
809
+ console.log('━'.repeat(63))
810
+ console.log()
811
+ console.log(`${c.success('✅')} ${c.bold('Setup Complete!')}`)
812
+ console.log()
813
+ console.log(` Configuration saved to ${c.dim('~/.ralph-ui/config.json')}`)
814
+ console.log()
815
+ console.log(' To start Ralph UI:')
816
+ console.log(` ${c.info('npx ralph-ui')}`)
817
+ console.log()
818
+ console.log(' To reconfigure:')
819
+ console.log(` ${c.info('npx ralph-ui --setup')}`)
820
+ console.log()
821
+ } finally {
822
+ prompt.close()
823
+ }
824
+
825
+ return config
826
+ }
827
+
828
+ // ============================================================================
829
+ // First run experience
830
+ // ============================================================================
831
+
832
+ async function firstRunExperience() {
833
+ console.log()
834
+ console.log(`╔${'═'.repeat(61)}╗`)
835
+ console.log(`║${' '.repeat(16)}Welcome to Ralph UI! 🎉${' '.repeat(22)}║`)
836
+ console.log(`╠${'═'.repeat(61)}╣`)
837
+
838
+ const { version } = await downloadBinary(null, true)
839
+
840
+ console.log(`╚${'═'.repeat(61)}╝`)
841
+
842
+ // Create initial config
843
+ let config = getDefaultConfig()
844
+ config = await runSetupWizard(config, true, version)
845
+
846
+ return config
847
+ }
848
+
849
+ // ============================================================================
850
+ // CLI argument parsing
851
+ // ============================================================================
852
+
853
+ function parseArgs(args) {
854
+ const flags = {
855
+ help: false,
856
+ version: false,
857
+ update: false,
858
+ forceUpdate: false,
859
+ skipUpdate: false,
860
+ offline: false,
861
+ setup: false,
862
+ config: false,
863
+ serverArgs: [],
864
+ }
865
+
866
+ for (let i = 0; i < args.length; i++) {
867
+ const arg = args[i]
868
+ switch (arg) {
869
+ case '--help':
870
+ case '-h':
871
+ flags.help = true
872
+ break
873
+ case '--version':
874
+ case '-v':
875
+ flags.version = true
876
+ break
877
+ case '--update':
878
+ flags.update = true
879
+ flags.forceUpdate = true
880
+ break
881
+ case '--skip-update':
882
+ flags.skipUpdate = true
883
+ break
884
+ case '--offline':
885
+ flags.offline = true
886
+ flags.skipUpdate = true
887
+ break
888
+ case '--setup':
889
+ flags.setup = true
890
+ break
891
+ case '--config':
892
+ flags.config = true
893
+ break
894
+ default:
895
+ // Pass remaining args to the server
896
+ flags.serverArgs.push(arg)
897
+ break
898
+ }
899
+ }
900
+
901
+ return flags
902
+ }
903
+
904
+ function showHelp() {
905
+ console.log(`
906
+ ${c.bold('Ralph UI')} - HTTP/WebSocket server for AI coding agents
907
+
908
+ ${c.bold('USAGE')}
909
+ npx ralph-ui [OPTIONS] [-- SERVER_ARGS]
910
+
911
+ ${c.bold('OPTIONS')}
912
+ ${c.info('--help, -h')} Show this help message
913
+ ${c.info('--version, -v')} Show current version
914
+ ${c.info('--update')} Force update to latest version
915
+ ${c.info('--skip-update')} Skip update check for this run
916
+ ${c.info('--offline')} Run without any network checks
917
+ ${c.info('--setup')} Re-run the setup wizard
918
+ ${c.info('--config')} Show current configuration
919
+
920
+ ${c.bold('SERVER OPTIONS')}
921
+ ${c.info('--port <port>')} Server port (default: 3420)
922
+ ${c.info('--bind <addr>')} Bind address (default: 0.0.0.0)
923
+ ${c.info('--token <token>')} Auth token (or use RALPH_SERVER_TOKEN env var)
924
+
925
+ ${c.bold('EXAMPLES')}
926
+ npx ralph-ui # Start with defaults
927
+ npx ralph-ui --port 8080 # Custom port
928
+ npx ralph-ui --setup # Re-run setup wizard
929
+ npx ralph-ui --update # Force update
930
+
931
+ ${c.bold('CONFIGURATION')}
932
+ Config file: ~/.ralph-ui/config.json
933
+ Binary: ~/.ralph-ui/bin/ralph-ui
934
+
935
+ ${c.bold('MORE INFO')}
936
+ https://github.com/dario-valles/Ralph-UI
937
+ `)
938
+ }
939
+
940
+ function showVersion() {
941
+ const version = getCurrentVersion()
942
+ if (version) {
943
+ console.log(`Ralph UI v${version}`)
944
+ } else {
945
+ console.log('Ralph UI (version unknown - not yet downloaded)')
946
+ }
947
+ }
948
+
949
+ function showConfig() {
950
+ const config = loadConfig()
951
+ const version = getCurrentVersion()
952
+
953
+ console.log()
954
+ console.log(c.bold('Ralph UI Configuration'))
955
+ console.log('━'.repeat(40))
956
+ console.log()
957
+ console.log(` ${c.dim('Version:')} ${version || 'not installed'}`)
958
+ console.log(` ${c.dim('Config file:')} ~/.ralph-ui/config.json`)
959
+ console.log(` ${c.dim('Binary:')} ~/.ralph-ui/bin/ralph-ui`)
960
+ console.log()
961
+ console.log(
962
+ ` ${c.dim('Auto-update:')} ${config.autoUpdate ? c.success('enabled') : c.warn('disabled')}`
963
+ )
964
+ console.log(` ${c.dim('Notify only:')} ${config.notifyOnly ? 'yes' : 'no'}`)
965
+ console.log(` ${c.dim('Access mode:')} ${config.accessMode}`)
966
+ console.log(` ${c.dim('Tunnel:')} ${config.tunnel.provider || 'not configured'}`)
967
+ console.log(` ${c.dim('Server port:')} ${config.server.port}`)
968
+ console.log(
969
+ ` ${c.dim('Token:')} ${config.server.token === 'env' ? '(from env)' : config.server.token ? '(set)' : '(random)'}`
970
+ )
971
+ console.log(` ${c.dim('Setup done:')} ${config.setupCompleted ? 'yes' : 'no'}`)
972
+ console.log()
973
+ }
974
+
975
+ // ============================================================================
976
+ // Main entry point
977
+ // ============================================================================
978
+
979
+ async function main() {
980
+ const args = process.argv.slice(2)
981
+ const flags = parseArgs(args)
982
+
983
+ // Handle help
984
+ if (flags.help) {
985
+ showHelp()
986
+ return
987
+ }
988
+
989
+ // Handle version
990
+ if (flags.version) {
991
+ showVersion()
992
+ return
993
+ }
994
+
995
+ // Handle config display
996
+ if (flags.config) {
997
+ showConfig()
998
+ return
999
+ }
1000
+
1001
+ // Load or create config
1002
+ let config = loadConfig()
1003
+ const binaryPath = getBinaryPath()
1004
+ const isFirstRun = !existsSync(binaryPath)
1005
+ const needsSetup = !config.setupCompleted
1006
+
1007
+ // First run - download and setup
1008
+ if (isFirstRun) {
1009
+ config = await firstRunExperience()
1010
+ // After setup, start the server
1011
+ } else if (flags.setup) {
1012
+ // Re-run setup wizard explicitly requested
1013
+ config = await runSetupWizard(config, false)
1014
+ return // Don't start server after explicit --setup
1015
+ } else if (needsSetup) {
1016
+ // Binary exists but setup was never completed - run setup automatically
1017
+ console.log()
1018
+ console.log(`${c.info('👋')} ${c.bold('Welcome back!')} Setup wasn't completed last time.`)
1019
+ config = await runSetupWizard(config, true)
1020
+ // Continue to start the server after setup
1021
+ }
1022
+
1023
+ // Force update
1024
+ if (flags.forceUpdate) {
1025
+ const currentVersion = getCurrentVersion()
1026
+ try {
1027
+ const release = await getLatestRelease()
1028
+ const latestVersion = release.tag_name.replace(/^v/, '')
1029
+
1030
+ if (currentVersion && !isNewerVersion(currentVersion, latestVersion)) {
1031
+ console.log(c.success(`✓ Already at latest version (v${currentVersion})`))
1032
+ } else {
1033
+ await performUpdate(currentVersion || '0.0.0', latestVersion)
1034
+ }
1035
+ } catch (error) {
1036
+ console.error(c.error('Failed to check for updates:'), error.message)
1037
+ if (!existsSync(binaryPath)) {
1038
+ process.exit(1)
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ // Check for updates (non-first run, non-forced)
1044
+ if (!isFirstRun && !flags.forceUpdate) {
1045
+ const updateInfo = await checkForUpdates(config, flags)
1046
+
1047
+ if (updateInfo.hasUpdate) {
1048
+ if (config.autoUpdate && !config.notifyOnly) {
1049
+ // Auto-update
1050
+ await performUpdate(updateInfo.currentVersion, updateInfo.latestVersion)
1051
+ } else {
1052
+ // Show update banner
1053
+ showUpdateBanner(updateInfo.currentVersion, updateInfo.latestVersion)
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ // Ensure binary exists
1059
+ if (!existsSync(binaryPath)) {
1060
+ console.error(c.error('Binary not found. Run without --skip-update to download.'))
1061
+ process.exit(1)
1062
+ }
1063
+
1064
+ // Build server arguments
1065
+ const serverArgs = [...flags.serverArgs]
1066
+
1067
+ // Add token from config if set
1068
+ if (config.server.token && config.server.token !== 'env') {
1069
+ // Check if token not already provided
1070
+ if (!serverArgs.includes('--token') && !process.env.RALPH_SERVER_TOKEN) {
1071
+ serverArgs.push('--token', config.server.token)
1072
+ }
1073
+ }
1074
+
1075
+ // Check for Tailscale serve and show the URL
1076
+ if (config.tunnel?.provider === 'tailscale') {
1077
+ const tailscaleUrl = await getTailscaleServeUrl()
1078
+ if (tailscaleUrl) {
1079
+ console.log(`\n${c.info('🔒')} Tailscale: ${c.bold(tailscaleUrl)}`)
1080
+ }
1081
+ }
1082
+
1083
+ // Start the server
1084
+ const child = spawn(binaryPath, serverArgs, {
1085
+ stdio: 'inherit',
1086
+ env: process.env,
1087
+ })
1088
+
1089
+ // Forward signals to child process so Ctrl+C properly terminates the server
1090
+ const forwardSignal = (signal) => {
1091
+ if (child.pid) {
1092
+ child.kill(signal)
1093
+ }
1094
+ }
1095
+
1096
+ process.on('SIGINT', () => forwardSignal('SIGINT'))
1097
+ process.on('SIGTERM', () => forwardSignal('SIGTERM'))
1098
+
1099
+ child.on('error', (error) => {
1100
+ console.error(c.error('Failed to start Ralph UI:'), error.message)
1101
+ process.exit(1)
1102
+ })
1103
+
1104
+ child.on('exit', (code, signal) => {
1105
+ // Remove signal handlers to prevent duplicate handling
1106
+ process.removeAllListeners('SIGINT')
1107
+ process.removeAllListeners('SIGTERM')
1108
+ process.exit(code ?? (signal === 'SIGINT' ? 130 : 1))
1109
+ })
1110
+ }
1111
+
1112
+ main().catch((error) => {
1113
+ console.error(c.error('Error:'), error.message)
1114
+ process.exit(1)
1115
+ })