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.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/bin/ralph-ui.js +1115 -0
- package/package.json +130 -0
package/bin/ralph-ui.js
ADDED
|
@@ -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
|
+
})
|