@exreve/exk 1.0.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/README.md +109 -0
- package/agentLogger.ts +162 -0
- package/agentSession.ts +1176 -0
- package/app-child.ts +2769 -0
- package/appManager.ts +275 -0
- package/appRunner.ts +475 -0
- package/bin/exk +45 -0
- package/container-entrypoint.sh +177 -0
- package/index.ts +2798 -0
- package/install-service.sh +122 -0
- package/moduleMcpServer.ts +131 -0
- package/package.json +67 -0
- package/projectAnalyzer.ts +341 -0
- package/projectManager.ts +111 -0
- package/runnerGenerator.ts +218 -0
- package/shared/types.ts +488 -0
- package/skills/code-review.md +49 -0
- package/skills/front-glass.md +36 -0
- package/skills/frontend-design.md +41 -0
- package/skills/index.ts +151 -0
- package/tsconfig.json +22 -0
- package/updater.ts +512 -0
package/app-child.ts
ADDED
|
@@ -0,0 +1,2769 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TalkToCode CLI - Child Process (App Bundle)
|
|
4
|
+
*
|
|
5
|
+
* This is the child process that contains all application logic.
|
|
6
|
+
* It is spawned by the updater process (updater.ts).
|
|
7
|
+
*
|
|
8
|
+
* This file is what gets updated during updates - the updater stays constant.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Command } from 'commander'
|
|
12
|
+
import { io, Socket } from 'socket.io-client'
|
|
13
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
14
|
+
import fs from 'fs/promises'
|
|
15
|
+
import fsSync from 'fs'
|
|
16
|
+
import path from 'path'
|
|
17
|
+
import os, { networkInterfaces } from 'os'
|
|
18
|
+
import { Buffer } from 'buffer'
|
|
19
|
+
import crypto from 'crypto'
|
|
20
|
+
import type {
|
|
21
|
+
Session,
|
|
22
|
+
Device,
|
|
23
|
+
SessionOutput,
|
|
24
|
+
Project,
|
|
25
|
+
ProjectCreateData,
|
|
26
|
+
ProjectDeleteData,
|
|
27
|
+
SessionCreateData,
|
|
28
|
+
SessionPromptData,
|
|
29
|
+
SessionSubscribeData,
|
|
30
|
+
SuccessResponse,
|
|
31
|
+
SessionResponse,
|
|
32
|
+
SessionsListResponse,
|
|
33
|
+
DevicesListResponse,
|
|
34
|
+
} from './shared/types'
|
|
35
|
+
import { createProject, deleteProject } from './projectManager'
|
|
36
|
+
import { agentSessionManager } from './agentSession'
|
|
37
|
+
import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer'
|
|
38
|
+
import { generateRunnerCode } from './runnerGenerator'
|
|
39
|
+
import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager'
|
|
40
|
+
import { spawn, execSync } from 'child_process'
|
|
41
|
+
import readline from 'readline'
|
|
42
|
+
import { fileURLToPath } from 'url'
|
|
43
|
+
import { createHash } from 'crypto'
|
|
44
|
+
|
|
45
|
+
// ============ Update IPC (Child -> Updater) ============
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Request update from parent updater process
|
|
49
|
+
*/
|
|
50
|
+
function requestUpdateFromParent(): void {
|
|
51
|
+
const updaterPid = process.env.TTC_UPDATER_PID
|
|
52
|
+
if (updaterPid) {
|
|
53
|
+
try {
|
|
54
|
+
// Send SIGUSR1 to parent to request update
|
|
55
|
+
process.kill(parseInt(updaterPid, 10), 'SIGUSR1')
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Failed to request update from parent:', error)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Notify parent that we want to restart after update
|
|
64
|
+
*/
|
|
65
|
+
function requestRestartFromParent(): void {
|
|
66
|
+
const updaterPid = process.env.TTC_UPDATER_PID
|
|
67
|
+
if (updaterPid) {
|
|
68
|
+
try {
|
|
69
|
+
// Send SIGUSR2 to parent to request restart
|
|
70
|
+
process.kill(parseInt(updaterPid, 10), 'SIGUSR2')
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error('Failed to request restart from parent:', error)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============ Types ============
|
|
78
|
+
|
|
79
|
+
interface Config {
|
|
80
|
+
apiUrl: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DeviceData {
|
|
84
|
+
deviceId: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ============ Constants ============
|
|
88
|
+
|
|
89
|
+
const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code')
|
|
90
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
91
|
+
const DEVICE_ID_FILE = path.join(CONFIG_DIR, 'device-id.json')
|
|
92
|
+
const DEFAULT_API_URL = 'https://api.talk-to-code.com'
|
|
93
|
+
|
|
94
|
+
// ============ Helpers ============
|
|
95
|
+
|
|
96
|
+
async function readConfig(): Promise<Config> {
|
|
97
|
+
try {
|
|
98
|
+
const data = await fs.readFile(CONFIG_FILE, 'utf-8')
|
|
99
|
+
return JSON.parse(data)
|
|
100
|
+
} catch {
|
|
101
|
+
return { apiUrl: DEFAULT_API_URL }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function writeConfig(config: Config): Promise<void> {
|
|
106
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true })
|
|
107
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function getDeviceId(): Promise<string> {
|
|
111
|
+
try {
|
|
112
|
+
const data = await fs.readFile(DEVICE_ID_FILE, 'utf-8')
|
|
113
|
+
return JSON.parse(data).deviceId
|
|
114
|
+
} catch {
|
|
115
|
+
const deviceId = uuidv4()
|
|
116
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true })
|
|
117
|
+
await fs.writeFile(DEVICE_ID_FILE, JSON.stringify({ deviceId }, null, 2))
|
|
118
|
+
return deviceId
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getLocalIpAddress(): string {
|
|
123
|
+
const nets = networkInterfaces()
|
|
124
|
+
for (const name of Object.keys(nets)) {
|
|
125
|
+
for (const net of nets[name]!) {
|
|
126
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
127
|
+
return net.address
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return 'Unknown'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getHostname(): string {
|
|
135
|
+
return os.hostname() || 'Unknown'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function connect(): Promise<Socket> {
|
|
139
|
+
const config = await readConfig()
|
|
140
|
+
const deviceId = await getDeviceId()
|
|
141
|
+
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const socket: Socket = io(config.apiUrl, {
|
|
144
|
+
transports: ['websocket', 'polling'],
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
socket.on('connect', () => {
|
|
148
|
+
socket.emit('register', { type: 'cli', deviceId })
|
|
149
|
+
resolve(socket)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
socket.on('registered', ({ type }: { type: string }) => {
|
|
153
|
+
console.log(`Connected as ${type}`)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
socket.on('connect_error', (err) => {
|
|
157
|
+
reject(err)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============ Update Helpers ============
|
|
163
|
+
|
|
164
|
+
type UpdateCheckResponse = {
|
|
165
|
+
updateAvailable: boolean
|
|
166
|
+
downloadUrl?: string
|
|
167
|
+
hash?: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const CURRENT_FILE = fileURLToPath(import.meta.url)
|
|
171
|
+
const __dirname = path.dirname(CURRENT_FILE)
|
|
172
|
+
|
|
173
|
+
function getCliHash(): string {
|
|
174
|
+
return createHash('sha256').update(fsSync.readFileSync(CURRENT_FILE)).digest('hex')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function checkForUpdate(): Promise<UpdateCheckResponse | null> {
|
|
178
|
+
try {
|
|
179
|
+
const config = await readConfig()
|
|
180
|
+
const res = await fetch(`${config.apiUrl}/update/check`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify({ hash: getCliHash(), platform: os.platform() })
|
|
184
|
+
})
|
|
185
|
+
if (!res.ok) return null
|
|
186
|
+
return await res.json() as UpdateCheckResponse
|
|
187
|
+
} catch {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function replaceSelf(tarballBuffer: Buffer): Promise<void> {
|
|
193
|
+
const extractDir = path.join(os.tmpdir(), `ttc-update-${Date.now()}`)
|
|
194
|
+
await fs.mkdir(extractDir, { recursive: true })
|
|
195
|
+
const tarPath = path.join(extractDir, 'ttc-cli.tar.gz')
|
|
196
|
+
await fs.writeFile(tarPath, tarballBuffer)
|
|
197
|
+
execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`)
|
|
198
|
+
await fs.unlink(tarPath)
|
|
199
|
+
|
|
200
|
+
// Preserve user config/token files (never overwrite on update)
|
|
201
|
+
const preserveFiles = ['device-config.json', 'device-id.json', 'config.json']
|
|
202
|
+
const preserved: Record<string, Buffer> = {}
|
|
203
|
+
for (const f of preserveFiles) {
|
|
204
|
+
try {
|
|
205
|
+
preserved[f] = await fs.readFile(path.join(CONFIG_DIR, f))
|
|
206
|
+
} catch {
|
|
207
|
+
/* file may not exist */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const cliDest = path.join(CONFIG_DIR, 'cli')
|
|
212
|
+
const sharedDest = path.join(CONFIG_DIR, 'shared')
|
|
213
|
+
await fs.rm(cliDest, { recursive: true, force: true })
|
|
214
|
+
await fs.rm(sharedDest, { recursive: true, force: true })
|
|
215
|
+
await fs.cp(path.join(extractDir, 'cli'), cliDest, { recursive: true })
|
|
216
|
+
await fs.cp(path.join(extractDir, 'shared'), sharedDest, { recursive: true })
|
|
217
|
+
await fs.copyFile(path.join(extractDir, 'package.json'), path.join(CONFIG_DIR, 'package.json'))
|
|
218
|
+
await fs.rm(extractDir, { recursive: true, force: true })
|
|
219
|
+
|
|
220
|
+
// Restore preserved config
|
|
221
|
+
for (const f of preserveFiles) {
|
|
222
|
+
if (preserved[f]) await fs.writeFile(path.join(CONFIG_DIR, f), preserved[f])
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
console.log('✓ CLI updated')
|
|
226
|
+
|
|
227
|
+
const npmPaths = [path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'), path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')]
|
|
228
|
+
const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm'
|
|
229
|
+
try {
|
|
230
|
+
execSync(`"${npmBin}" install --omit=dev`, { cwd: CONFIG_DIR, stdio: 'inherit' })
|
|
231
|
+
console.log('✓ Dependencies updated')
|
|
232
|
+
} catch {
|
|
233
|
+
console.warn('⚠ npm install failed')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function selfUpdate(force = false): Promise<void> {
|
|
238
|
+
const info = await checkForUpdate()
|
|
239
|
+
if (!info || !info.updateAvailable) {
|
|
240
|
+
console.log('✓ Already up to date')
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.log('📦 Update available!')
|
|
245
|
+
if (!force && process.stdin.isTTY) {
|
|
246
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
247
|
+
const answer = await new Promise<string>(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()) }))
|
|
248
|
+
if (answer.toLowerCase().startsWith('n')) return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!info.downloadUrl || !info.hash) return
|
|
252
|
+
|
|
253
|
+
console.log('Downloading...')
|
|
254
|
+
const res = await fetch(info.downloadUrl)
|
|
255
|
+
if (!res.ok) throw new Error('Download failed')
|
|
256
|
+
|
|
257
|
+
const bundle = Buffer.from(await res.arrayBuffer())
|
|
258
|
+
if (createHash('sha256').update(bundle).digest('hex') !== info.hash) {
|
|
259
|
+
throw new Error('Hash mismatch')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await replaceSelf(bundle)
|
|
263
|
+
process.exit(0)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ============ Commands ============
|
|
267
|
+
|
|
268
|
+
async function registerDevice(name: string | undefined, email: string | undefined): Promise<void> {
|
|
269
|
+
try {
|
|
270
|
+
const deviceId = await getDeviceId()
|
|
271
|
+
let deviceEmail = email
|
|
272
|
+
|
|
273
|
+
// If we already have a valid token, skip approval email and just register
|
|
274
|
+
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
275
|
+
try {
|
|
276
|
+
const deviceConfig = JSON.parse(await fs.readFile(deviceConfigFile, 'utf-8'))
|
|
277
|
+
const existingToken = deviceConfig.authToken
|
|
278
|
+
if (existingToken && typeof existingToken === 'string') {
|
|
279
|
+
const config = await readConfig()
|
|
280
|
+
const meRes = await fetch(`${config.apiUrl}/auth/me`, {
|
|
281
|
+
headers: { Authorization: `Bearer ${existingToken}` }
|
|
282
|
+
})
|
|
283
|
+
const meData = await meRes.json() as { success?: boolean; user?: { email?: string }; error?: string }
|
|
284
|
+
if (meRes.ok && meData.success && meData.user?.email) {
|
|
285
|
+
console.log(`✓ Using existing auth token for ${meData.user.email}`)
|
|
286
|
+
deviceEmail = deviceEmail || meData.user.email
|
|
287
|
+
const socket = await connect()
|
|
288
|
+
await new Promise<void>((resolve, reject) => {
|
|
289
|
+
socket.emit('device:register', {
|
|
290
|
+
deviceId,
|
|
291
|
+
name: name || `CLI Device ${deviceId.slice(0, 8)}`,
|
|
292
|
+
ipAddress: getLocalIpAddress(),
|
|
293
|
+
hostname: getHostname(),
|
|
294
|
+
email: deviceEmail
|
|
295
|
+
}, ({ success, device, error }: {
|
|
296
|
+
success: boolean
|
|
297
|
+
device?: Device
|
|
298
|
+
error?: string
|
|
299
|
+
}) => {
|
|
300
|
+
socket.disconnect()
|
|
301
|
+
if (success && device) {
|
|
302
|
+
console.log(`✓ Device registered!`)
|
|
303
|
+
console.log(`Device ID: ${device.deviceId}`)
|
|
304
|
+
console.log(`Name: ${device.name}`)
|
|
305
|
+
console.log(`Email: ${device.email}`)
|
|
306
|
+
resolve()
|
|
307
|
+
} else {
|
|
308
|
+
reject(new Error(error || 'Registration failed'))
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// No config or invalid token - continue with approval flow
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Prompt for email if not provided
|
|
320
|
+
if (!deviceEmail) {
|
|
321
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
322
|
+
deviceEmail = await new Promise<string>((resolve) => {
|
|
323
|
+
rl.question('Enter your email address: ', (answer) => {
|
|
324
|
+
rl.close()
|
|
325
|
+
resolve(answer.trim())
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!deviceEmail?.includes('@')) {
|
|
331
|
+
console.error('✗ Valid email address is required')
|
|
332
|
+
throw new Error('Valid email address is required')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Generate 32-byte random token (base64url encoded)
|
|
336
|
+
const randomBytes = crypto.randomBytes(32)
|
|
337
|
+
const approvalToken = randomBytes.toString('base64url') // base64url encoding (no padding, URL-safe)
|
|
338
|
+
|
|
339
|
+
console.log(`\n📧 Sending approval request to ${deviceEmail}...`)
|
|
340
|
+
|
|
341
|
+
// Send approval request
|
|
342
|
+
const config = await readConfig()
|
|
343
|
+
const response = await fetch(`${config.apiUrl}/auth/approval-request`, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: {
|
|
346
|
+
'Content-Type': 'application/json'
|
|
347
|
+
},
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
email: deviceEmail,
|
|
350
|
+
deviceId,
|
|
351
|
+
approvalToken
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const result = await response.json() as { success: boolean; error?: string }
|
|
356
|
+
|
|
357
|
+
if (!result.success) {
|
|
358
|
+
console.error(`✗ Failed to send approval request: ${result.error || 'Unknown error'}`)
|
|
359
|
+
throw new Error(result.error || 'Failed to send approval request')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
console.log(`✓ Approval email sent! Check your inbox (${deviceEmail})`)
|
|
363
|
+
console.log(`⏳ Waiting for approval...`)
|
|
364
|
+
|
|
365
|
+
// Poll every 5 seconds for approval
|
|
366
|
+
const maxAttempts = 120 // 10 minutes max (120 * 5s)
|
|
367
|
+
let attempts = 0
|
|
368
|
+
|
|
369
|
+
const checkApproval = async (): Promise<string | null> => {
|
|
370
|
+
attempts++
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const statusResponse = await fetch(`${config.apiUrl}/auth/approval-status?token=${approvalToken}`)
|
|
374
|
+
const statusResult = await statusResponse.json() as { success?: boolean; approved?: boolean; token?: string; error?: string; pending?: boolean }
|
|
375
|
+
|
|
376
|
+
if (statusResult.success && statusResult.approved) {
|
|
377
|
+
return statusResult.token || null // Return permanent auth token
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (statusResult.error && !statusResult.pending) {
|
|
381
|
+
console.error(`\n✗ ${statusResult.error}`)
|
|
382
|
+
return null
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return null // Still pending
|
|
386
|
+
} catch (error: any) {
|
|
387
|
+
console.error(`\n✗ Error checking approval status: ${error.message}`)
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Poll until approved or timeout
|
|
393
|
+
while (attempts < maxAttempts) {
|
|
394
|
+
await new Promise(resolve => setTimeout(resolve, 5000)) // Wait 5 seconds
|
|
395
|
+
|
|
396
|
+
const token = await checkApproval()
|
|
397
|
+
if (token) {
|
|
398
|
+
// Device approved! Store permanent token
|
|
399
|
+
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
400
|
+
await fs.writeFile(deviceConfigFile, JSON.stringify({
|
|
401
|
+
email: deviceEmail,
|
|
402
|
+
authToken: token
|
|
403
|
+
}, null, 2))
|
|
404
|
+
|
|
405
|
+
console.log(`\n✓ Device approved!`)
|
|
406
|
+
|
|
407
|
+
// Register device with backend and wait for completion
|
|
408
|
+
const socket = await connect()
|
|
409
|
+
|
|
410
|
+
await new Promise<void>((resolve, reject) => {
|
|
411
|
+
socket.emit('device:register', {
|
|
412
|
+
deviceId,
|
|
413
|
+
name: name || `CLI Device ${deviceId.slice(0, 8)}`,
|
|
414
|
+
ipAddress: getLocalIpAddress(),
|
|
415
|
+
hostname: getHostname(),
|
|
416
|
+
email: deviceEmail
|
|
417
|
+
}, ({ success, device, error }: {
|
|
418
|
+
success: boolean
|
|
419
|
+
device?: Device
|
|
420
|
+
error?: string
|
|
421
|
+
}) => {
|
|
422
|
+
socket.disconnect()
|
|
423
|
+
if (success && device) {
|
|
424
|
+
console.log(`✓ Device registered!`)
|
|
425
|
+
console.log(`Device ID: ${device.deviceId}`)
|
|
426
|
+
console.log(`Name: ${device.name}`)
|
|
427
|
+
console.log(`Email: ${device.email}`)
|
|
428
|
+
console.log(`IP Address: ${device.ipAddress || 'Unknown'}`)
|
|
429
|
+
console.log(`Hostname: ${device.hostname || 'Unknown'}`)
|
|
430
|
+
resolve()
|
|
431
|
+
} else {
|
|
432
|
+
reject(new Error(error || 'Registration failed'))
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
return // Successfully registered
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Show progress every 30 seconds
|
|
441
|
+
if (attempts % 6 === 0) {
|
|
442
|
+
process.stdout.write(`\r⏳ Still waiting... (${attempts * 5}s)`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
console.error(`\n✗ Approval timeout. Please check your email and try again.`)
|
|
447
|
+
throw new Error('Approval timeout')
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error('Failed to register device:', (error as Error).message)
|
|
450
|
+
throw error
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function listSessions(): Promise<void> {
|
|
455
|
+
try {
|
|
456
|
+
const socket = await connect()
|
|
457
|
+
|
|
458
|
+
socket.emit('sessions:list', (response: SessionsListResponse) => {
|
|
459
|
+
const { sessions } = response
|
|
460
|
+
console.log('\nSessions:')
|
|
461
|
+
console.log('-'.repeat(60))
|
|
462
|
+
sessions.forEach((session, idx) => {
|
|
463
|
+
console.log(`${idx + 1}. ${session.id}`)
|
|
464
|
+
console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`)
|
|
465
|
+
console.log()
|
|
466
|
+
})
|
|
467
|
+
socket.disconnect()
|
|
468
|
+
})
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error('Failed to list sessions:', (error as Error).message)
|
|
471
|
+
process.exit(1)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function createSession(): Promise<void> {
|
|
476
|
+
try {
|
|
477
|
+
const socket = await connect()
|
|
478
|
+
|
|
479
|
+
socket.emit('session:create', (response: SessionResponse) => {
|
|
480
|
+
const { success, session } = response
|
|
481
|
+
if (success && session) {
|
|
482
|
+
console.log('Session created!')
|
|
483
|
+
console.log('Session ID:', session.id)
|
|
484
|
+
socket.disconnect()
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
} catch (error) {
|
|
488
|
+
console.error('Failed to create session:', (error as Error).message)
|
|
489
|
+
process.exit(1)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function sendPrompt(sessionId: string, prompt: string): Promise<void> {
|
|
494
|
+
try {
|
|
495
|
+
const socket = await connect()
|
|
496
|
+
|
|
497
|
+
socket.emit('session:subscribe', { sessionId })
|
|
498
|
+
|
|
499
|
+
socket.on('prompt:output', ({ data, type }: { data: string; type: string }) => {
|
|
500
|
+
if (type === 'stdout') {
|
|
501
|
+
process.stdout.write(data)
|
|
502
|
+
} else {
|
|
503
|
+
process.stderr.write(data)
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
socket.on('session:exited', ({ exitCode }: { exitCode: number | null }) => {
|
|
508
|
+
console.log(`\nSession exited with code ${exitCode}`)
|
|
509
|
+
socket.disconnect()
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
socket.emit('session:prompt', { sessionId, prompt }, ({ success }: { success: boolean }) => {
|
|
513
|
+
if (!success) {
|
|
514
|
+
console.error('Failed to send prompt')
|
|
515
|
+
socket.disconnect()
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
process.on('SIGINT', () => {
|
|
520
|
+
socket.emit('session:unsubscribe', { sessionId } as SessionSubscribeData)
|
|
521
|
+
socket.disconnect()
|
|
522
|
+
process.exit(0)
|
|
523
|
+
})
|
|
524
|
+
} catch (error) {
|
|
525
|
+
console.error('Failed to send prompt:', (error as Error).message)
|
|
526
|
+
process.exit(1)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function listDevices(): Promise<void> {
|
|
531
|
+
try {
|
|
532
|
+
const socket = await connect()
|
|
533
|
+
|
|
534
|
+
socket.emit('devices:list', (response: DevicesListResponse) => {
|
|
535
|
+
const { devices } = response
|
|
536
|
+
console.log('\nRegistered Devices:')
|
|
537
|
+
console.log('-'.repeat(60))
|
|
538
|
+
devices.forEach((device, idx) => {
|
|
539
|
+
console.log(`${idx + 1}. ${device.name}`)
|
|
540
|
+
console.log(` ID: ${device.deviceId}`)
|
|
541
|
+
console.log(` IP Address: ${device.ipAddress || 'Unknown'}`)
|
|
542
|
+
console.log(` Hostname: ${device.hostname || 'Unknown'}`)
|
|
543
|
+
console.log(` Registered: ${new Date(device.registeredAt).toLocaleString()}`)
|
|
544
|
+
console.log(` Last Seen: ${new Date(device.lastSeen).toLocaleString()}`)
|
|
545
|
+
console.log()
|
|
546
|
+
})
|
|
547
|
+
socket.disconnect()
|
|
548
|
+
})
|
|
549
|
+
} catch (error) {
|
|
550
|
+
console.error('Failed to list devices:', (error as Error).message)
|
|
551
|
+
process.exit(1)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function monitorSession(sessionId: string): Promise<void> {
|
|
556
|
+
try {
|
|
557
|
+
const socket = await connect()
|
|
558
|
+
|
|
559
|
+
console.log(`Monitoring session ${sessionId}...`)
|
|
560
|
+
console.log('Press Ctrl+C to stop\n')
|
|
561
|
+
|
|
562
|
+
socket.emit('session:subscribe', { sessionId })
|
|
563
|
+
|
|
564
|
+
socket.on('session:state', ({ outputs, isActive }: { outputs: SessionOutput[]; isActive: boolean }) => {
|
|
565
|
+
console.log(`Session active: ${isActive}`)
|
|
566
|
+
console.log('Recent output:')
|
|
567
|
+
outputs.slice(-10).forEach(({ type, data }) => {
|
|
568
|
+
if (type === 'stdout') {
|
|
569
|
+
const dataString = typeof data === 'string' ? data : JSON.stringify(data)
|
|
570
|
+
process.stdout.write(dataString)
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
socket.on('prompt:output', ({ data, type }: { data: string; type: string }) => {
|
|
576
|
+
if (type === 'stdout') {
|
|
577
|
+
process.stdout.write(data)
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
socket.on('session:exited', ({ exitCode }: { exitCode: number | null }) => {
|
|
582
|
+
console.log(`\nSession exited with code ${exitCode}`)
|
|
583
|
+
socket.disconnect()
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
process.on('SIGINT', () => {
|
|
587
|
+
socket.emit('session:unsubscribe', { sessionId } as SessionSubscribeData)
|
|
588
|
+
socket.disconnect()
|
|
589
|
+
process.exit(0)
|
|
590
|
+
})
|
|
591
|
+
} catch (error) {
|
|
592
|
+
console.error('Failed to monitor session:', (error as Error).message)
|
|
593
|
+
process.exit(1)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Active sessions map - persists across reconnections
|
|
598
|
+
const activeSessions = new Map<string, { projectPath: string; currentPromptId?: string; model?: string }>()
|
|
599
|
+
|
|
600
|
+
async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
601
|
+
const config = await readConfig()
|
|
602
|
+
const deviceId = await getDeviceId()
|
|
603
|
+
const ipAddress = getLocalIpAddress()
|
|
604
|
+
const hostname = getHostname()
|
|
605
|
+
|
|
606
|
+
// Silent update check on startup
|
|
607
|
+
checkForUpdate().then(info => {
|
|
608
|
+
if (info?.updateAvailable) console.log('📦 Update available: run "ttc update"')
|
|
609
|
+
}).catch(() => {})
|
|
610
|
+
|
|
611
|
+
if (foreground) console.log('=== TalkToCode CLI (Foreground) ===')
|
|
612
|
+
else console.log('Starting daemon...')
|
|
613
|
+
console.log(`API: ${config.apiUrl}`)
|
|
614
|
+
console.log(`Device: ${deviceId}`)
|
|
615
|
+
console.log(`Host: ${hostname}`)
|
|
616
|
+
|
|
617
|
+
// Test if server is reachable
|
|
618
|
+
if (foreground) {
|
|
619
|
+
try {
|
|
620
|
+
const url = new URL(config.apiUrl)
|
|
621
|
+
const testUrl = `${url.protocol}//${url.host}`
|
|
622
|
+
console.log(`Testing connection to ${testUrl}...`)
|
|
623
|
+
const response = await fetch(testUrl, {
|
|
624
|
+
method: 'GET',
|
|
625
|
+
signal: AbortSignal.timeout(5000)
|
|
626
|
+
})
|
|
627
|
+
console.log(`✓ Server is reachable (HTTP ${response.status})`)
|
|
628
|
+
} catch (error: any) {
|
|
629
|
+
console.error(`✗ Cannot reach server: ${error.message}`)
|
|
630
|
+
console.error(` Make sure the backend server is running at ${config.apiUrl}`)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
console.log('')
|
|
634
|
+
|
|
635
|
+
let socket: Socket | null = null
|
|
636
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
637
|
+
let reconnectAttempts = 0
|
|
638
|
+
const MAX_RECONNECT_ATTEMPTS = Infinity // Keep trying forever
|
|
639
|
+
|
|
640
|
+
const connectAndRegister = async () => {
|
|
641
|
+
try {
|
|
642
|
+
socket = io(config.apiUrl, {
|
|
643
|
+
transports: ['websocket', 'polling'],
|
|
644
|
+
reconnection: true,
|
|
645
|
+
reconnectionDelay: 1000,
|
|
646
|
+
reconnectionDelayMax: 10000,
|
|
647
|
+
reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
|
|
648
|
+
timeout: 20000,
|
|
649
|
+
forceNew: true,
|
|
650
|
+
upgrade: true,
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
if (foreground) {
|
|
654
|
+
console.log(`Attempting to connect to: ${config.apiUrl}`)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Register event handlers once (outside registered callback to prevent duplicates)
|
|
658
|
+
// Project handlers
|
|
659
|
+
socket.on('project:create', async (data: ProjectCreateData, callback: (response: SuccessResponse & { actualPath?: string }) => void) => {
|
|
660
|
+
try {
|
|
661
|
+
if (foreground) {
|
|
662
|
+
console.log(`📁 Creating project: ${data.name} at ${data.path}`)
|
|
663
|
+
}
|
|
664
|
+
const result = await createProject({
|
|
665
|
+
projectId: data.projectId || uuidv4(),
|
|
666
|
+
name: data.name,
|
|
667
|
+
path: data.path,
|
|
668
|
+
sourcePath: data.sourcePath
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
if (result.success) {
|
|
672
|
+
if (foreground) {
|
|
673
|
+
console.log(`✓ Project created: ${data.name} at ${result.actualPath || data.path}`)
|
|
674
|
+
} else {
|
|
675
|
+
console.log(`Project created: ${data.name} at ${result.actualPath || data.path}`)
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
if (foreground) {
|
|
679
|
+
console.error(`✗ Failed to create project: ${result.error || 'Unknown error'}`)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
callback?.({
|
|
684
|
+
success: result.success,
|
|
685
|
+
error: result.error,
|
|
686
|
+
actualPath: result.actualPath
|
|
687
|
+
})
|
|
688
|
+
} catch (error: any) {
|
|
689
|
+
if (foreground) {
|
|
690
|
+
console.error(`✗ Error creating project: ${error.message}`)
|
|
691
|
+
}
|
|
692
|
+
callback?.({ success: false, error: error.message })
|
|
693
|
+
}
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
socket.on('project:delete', async (data: ProjectDeleteData & { path?: string }, callback: (response: SuccessResponse) => void) => {
|
|
697
|
+
try {
|
|
698
|
+
const { projectId, path } = data
|
|
699
|
+
if (!path) {
|
|
700
|
+
callback?.({ success: false, error: 'Project path required for deletion' })
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (foreground) {
|
|
705
|
+
console.log(`🗑️ Deleting project: ${projectId} at ${path}`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const result = await deleteProject(path)
|
|
709
|
+
|
|
710
|
+
if (result.success) {
|
|
711
|
+
if (foreground) {
|
|
712
|
+
console.log(`✓ Project deleted: ${projectId} at ${path}`)
|
|
713
|
+
} else {
|
|
714
|
+
console.log(`Project deleted: ${projectId} at ${path}`)
|
|
715
|
+
}
|
|
716
|
+
} else {
|
|
717
|
+
if (foreground) {
|
|
718
|
+
console.error(`✗ Failed to delete project: ${result.error || 'Unknown error'}`)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
callback?.(result)
|
|
723
|
+
} catch (error: any) {
|
|
724
|
+
if (foreground) {
|
|
725
|
+
console.error(`✗ Error deleting project: ${error.message}`)
|
|
726
|
+
}
|
|
727
|
+
callback?.({ success: false, error: error.message })
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// GitHub handlers
|
|
732
|
+
socket.on('github:status', async (data: { projectId: string; projectPath: string }, callback?: (response: { success: boolean; isRepo?: boolean; hasChanges?: boolean; branch?: string; remote?: string; changedFiles?: Array<{ path: string; status: string }>; changesCount?: number; error?: string }) => void) => {
|
|
733
|
+
try {
|
|
734
|
+
const { projectPath } = data
|
|
735
|
+
|
|
736
|
+
// Check if directory is a git repository
|
|
737
|
+
const gitDir = path.join(projectPath, '.git')
|
|
738
|
+
let isRepo = false
|
|
739
|
+
try {
|
|
740
|
+
const stats = await fs.stat(gitDir)
|
|
741
|
+
isRepo = stats.isDirectory()
|
|
742
|
+
} catch {
|
|
743
|
+
isRepo = false
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!isRepo) {
|
|
747
|
+
callback?.({ success: true, isRepo: false, hasChanges: false })
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Check git status
|
|
752
|
+
const { execSync } = await import('child_process')
|
|
753
|
+
try {
|
|
754
|
+
// Get current branch
|
|
755
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim()
|
|
756
|
+
|
|
757
|
+
// Get remote URL
|
|
758
|
+
let remote: string | undefined
|
|
759
|
+
try {
|
|
760
|
+
remote = execSync('git config --get remote.origin.url', { cwd: projectPath, encoding: 'utf-8' }).trim()
|
|
761
|
+
} catch {
|
|
762
|
+
// No remote configured
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Check for actual changes (modified, staged, deleted files - ignore untracked)
|
|
766
|
+
// Use --short to get concise output, and check for actual changes (not just untracked files)
|
|
767
|
+
let hasChanges = false
|
|
768
|
+
const changedFiles: Array<{ path: string; status: string }> = []
|
|
769
|
+
try {
|
|
770
|
+
const statusOutput = execSync('git status --porcelain', { cwd: projectPath, encoding: 'utf-8' })
|
|
771
|
+
const lines = statusOutput.trim().split('\n').filter(line => line.trim().length > 0)
|
|
772
|
+
|
|
773
|
+
for (const line of lines) {
|
|
774
|
+
const status = line.substring(0, 2).trim()
|
|
775
|
+
const filePath = line.substring(2).trim()
|
|
776
|
+
|
|
777
|
+
// Skip untracked files (??)
|
|
778
|
+
if (line.startsWith('??')) continue
|
|
779
|
+
|
|
780
|
+
// Include modified (M), added (A), deleted (D), renamed (R), copied (C)
|
|
781
|
+
if (status.includes('M') || status.includes('A') || status.includes('D') || status.includes('R') || status.includes('C')) {
|
|
782
|
+
hasChanges = true
|
|
783
|
+
changedFiles.push({ path: filePath, status })
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
hasChanges = false
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
callback?.({ success: true, isRepo: true, hasChanges, branch, remote, changedFiles, changesCount: changedFiles.length })
|
|
791
|
+
} catch (error: any) {
|
|
792
|
+
callback?.({ success: false, error: error.message })
|
|
793
|
+
}
|
|
794
|
+
} catch (error: any) {
|
|
795
|
+
callback?.({ success: false, error: error.message })
|
|
796
|
+
}
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
socket.on('github:commit', async (data: { projectId: string; projectPath: string; message?: string }, callback?: (response: { success: boolean; error?: string }) => void) => {
|
|
800
|
+
try {
|
|
801
|
+
const { projectPath, message = 'changes' } = data
|
|
802
|
+
|
|
803
|
+
// Check if directory is a git repository
|
|
804
|
+
const gitDir = path.join(projectPath, '.git')
|
|
805
|
+
let isRepo = false
|
|
806
|
+
try {
|
|
807
|
+
const stats = await fs.stat(gitDir)
|
|
808
|
+
isRepo = stats.isDirectory()
|
|
809
|
+
} catch {
|
|
810
|
+
callback?.({ success: false, error: 'Not a git repository' })
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const { execSync } = await import('child_process')
|
|
815
|
+
try {
|
|
816
|
+
// Stage all changes
|
|
817
|
+
execSync('git add -A', { cwd: projectPath })
|
|
818
|
+
|
|
819
|
+
// Commit with message
|
|
820
|
+
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath })
|
|
821
|
+
|
|
822
|
+
// Determine which branch to push to (master or main)
|
|
823
|
+
let targetBranch: string
|
|
824
|
+
try {
|
|
825
|
+
// Check if master branch exists
|
|
826
|
+
execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' })
|
|
827
|
+
targetBranch = 'master'
|
|
828
|
+
} catch {
|
|
829
|
+
try {
|
|
830
|
+
// Check if main branch exists
|
|
831
|
+
execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' })
|
|
832
|
+
targetBranch = 'main'
|
|
833
|
+
} catch {
|
|
834
|
+
// Fallback to current branch if neither master nor main exists
|
|
835
|
+
targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim()
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Push to origin after commit
|
|
840
|
+
execSync(`git push origin ${targetBranch}`, { cwd: projectPath, stdio: 'pipe' })
|
|
841
|
+
|
|
842
|
+
callback?.({ success: true })
|
|
843
|
+
} catch (error: any) {
|
|
844
|
+
callback?.({ success: false, error: error.message })
|
|
845
|
+
}
|
|
846
|
+
} catch (error: any) {
|
|
847
|
+
callback?.({ success: false, error: error.message })
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
socket.on('github:push', async (data: { projectId: string; projectPath: string }, callback?: (response: { success: boolean; error?: string }) => void) => {
|
|
852
|
+
try {
|
|
853
|
+
const { projectPath } = data
|
|
854
|
+
|
|
855
|
+
// Check if directory is a git repository
|
|
856
|
+
const gitDir = path.join(projectPath, '.git')
|
|
857
|
+
let isRepo = false
|
|
858
|
+
try {
|
|
859
|
+
const stats = await fs.stat(gitDir)
|
|
860
|
+
isRepo = stats.isDirectory()
|
|
861
|
+
} catch {
|
|
862
|
+
callback?.({ success: false, error: 'Not a git repository' })
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const { execSync } = await import('child_process')
|
|
867
|
+
try {
|
|
868
|
+
// Determine which branch to push to (master or main)
|
|
869
|
+
let targetBranch: string
|
|
870
|
+
try {
|
|
871
|
+
// Check if master branch exists
|
|
872
|
+
execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' })
|
|
873
|
+
targetBranch = 'master'
|
|
874
|
+
} catch {
|
|
875
|
+
try {
|
|
876
|
+
// Check if main branch exists
|
|
877
|
+
execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' })
|
|
878
|
+
targetBranch = 'main'
|
|
879
|
+
} catch {
|
|
880
|
+
// Fallback to current branch if neither master nor main exists
|
|
881
|
+
targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim()
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Push to origin
|
|
886
|
+
execSync(`git push origin ${targetBranch}`, { cwd: projectPath })
|
|
887
|
+
|
|
888
|
+
callback?.({ success: true })
|
|
889
|
+
} catch (error: any) {
|
|
890
|
+
callback?.({ success: false, error: error.message })
|
|
891
|
+
}
|
|
892
|
+
} catch (error: any) {
|
|
893
|
+
callback?.({ success: false, error: error.message })
|
|
894
|
+
}
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
socket.on('github:discard', async (data: { projectId: string; projectPath: string }, callback?: (response: { success: boolean; error?: string }) => void) => {
|
|
898
|
+
try {
|
|
899
|
+
const { projectPath } = data
|
|
900
|
+
|
|
901
|
+
// Check if directory is a git repository
|
|
902
|
+
const gitDir = path.join(projectPath, '.git')
|
|
903
|
+
let isRepo = false
|
|
904
|
+
try {
|
|
905
|
+
const stats = await fs.stat(gitDir)
|
|
906
|
+
isRepo = stats.isDirectory()
|
|
907
|
+
} catch {
|
|
908
|
+
callback?.({ success: false, error: 'Not a git repository' })
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const { execSync } = await import('child_process')
|
|
913
|
+
try {
|
|
914
|
+
// Discard all changes and revert to latest commit
|
|
915
|
+
// Reset all changes (both staged and unstaged)
|
|
916
|
+
execSync('git reset --hard HEAD', { cwd: projectPath })
|
|
917
|
+
// Clean untracked files and directories
|
|
918
|
+
execSync('git clean -fd', { cwd: projectPath })
|
|
919
|
+
|
|
920
|
+
callback?.({ success: true })
|
|
921
|
+
} catch (error: any) {
|
|
922
|
+
callback?.({ success: false, error: error.message })
|
|
923
|
+
}
|
|
924
|
+
} catch (error: any) {
|
|
925
|
+
callback?.({ success: false, error: error.message })
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
// Directory listing handler
|
|
930
|
+
socket.on('fs:list', async (data: { dirPath: string }) => {
|
|
931
|
+
try {
|
|
932
|
+
const { dirPath } = data
|
|
933
|
+
if (!dirPath) {
|
|
934
|
+
socket?.emit('fs:list:response', { success: false, error: 'dirPath is required' })
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const entries: Array<{ name: string; path: string; isDir: boolean }> = []
|
|
939
|
+
try {
|
|
940
|
+
const dirents = await fs.readdir(dirPath, { withFileTypes: true })
|
|
941
|
+
for (const dirent of dirents) {
|
|
942
|
+
if (dirent.name.startsWith('.')) continue // Skip hidden files
|
|
943
|
+
const fullPath = path.join(dirPath, dirent.name)
|
|
944
|
+
entries.push({
|
|
945
|
+
name: dirent.name,
|
|
946
|
+
path: fullPath,
|
|
947
|
+
isDir: dirent.isDirectory()
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
// Sort: directories first, then alphabetically
|
|
951
|
+
entries.sort((a, b) => {
|
|
952
|
+
if (a.isDir && !b.isDir) return -1
|
|
953
|
+
if (!a.isDir && b.isDir) return 1
|
|
954
|
+
return a.name.localeCompare(b.name)
|
|
955
|
+
})
|
|
956
|
+
socket?.emit('fs:list:response', { success: true, entries })
|
|
957
|
+
} catch (err: any) {
|
|
958
|
+
socket?.emit('fs:list:response', { success: false, error: err.message })
|
|
959
|
+
}
|
|
960
|
+
} catch (error: any) {
|
|
961
|
+
socket?.emit('fs:list:response', { success: false, error: error.message })
|
|
962
|
+
}
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
// File write handler
|
|
966
|
+
socket.on('fs:write', async (data: { filePath: string; content: string; encoding?: string }) => {
|
|
967
|
+
try {
|
|
968
|
+
const { filePath, content, encoding = 'utf-8' } = data
|
|
969
|
+
if (!filePath) {
|
|
970
|
+
socket?.emit('fs:write:response', { success: false, error: 'filePath is required' })
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
// Ensure directory exists
|
|
976
|
+
const dir = path.dirname(filePath)
|
|
977
|
+
await fs.mkdir(dir, { recursive: true })
|
|
978
|
+
|
|
979
|
+
// Write file
|
|
980
|
+
await fs.writeFile(filePath, content, encoding as BufferEncoding)
|
|
981
|
+
|
|
982
|
+
socket?.emit('fs:write:response', { success: true })
|
|
983
|
+
} catch (err: any) {
|
|
984
|
+
socket?.emit('fs:write:response', { success: false, error: err.message })
|
|
985
|
+
}
|
|
986
|
+
} catch (error: any) {
|
|
987
|
+
socket?.emit('fs:write:response', { success: false, error: error.message })
|
|
988
|
+
}
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
// File read handler
|
|
992
|
+
socket.on('fs:read', async (data: { filePath: string; encoding?: string }) => {
|
|
993
|
+
try {
|
|
994
|
+
const { filePath, encoding = 'utf-8' } = data
|
|
995
|
+
if (!filePath) {
|
|
996
|
+
socket?.emit('fs:read:response', { success: false, error: 'filePath is required' })
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
try {
|
|
1001
|
+
// Read file
|
|
1002
|
+
const content = await fs.readFile(filePath, encoding as BufferEncoding)
|
|
1003
|
+
|
|
1004
|
+
socket?.emit('fs:read:response', { success: true, content })
|
|
1005
|
+
} catch (err: any) {
|
|
1006
|
+
socket?.emit('fs:read:response', { success: false, error: err.message })
|
|
1007
|
+
}
|
|
1008
|
+
} catch (error: any) {
|
|
1009
|
+
socket?.emit('fs:read:response', { success: false, error: error.message })
|
|
1010
|
+
}
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
// ========== Update Handlers ==========
|
|
1014
|
+
|
|
1015
|
+
// Check for updates
|
|
1016
|
+
socket.on('update:check', async (data: {}, callback?: (response: {
|
|
1017
|
+
success: boolean
|
|
1018
|
+
updateAvailable?: boolean
|
|
1019
|
+
version?: string
|
|
1020
|
+
changelog?: string
|
|
1021
|
+
size?: number
|
|
1022
|
+
error?: string
|
|
1023
|
+
}) => void) => {
|
|
1024
|
+
try {
|
|
1025
|
+
const config = await readConfig()
|
|
1026
|
+
const currentHash = createHash('sha256').update(fsSync.readFileSync(fileURLToPath(import.meta.url))).digest('hex')
|
|
1027
|
+
|
|
1028
|
+
const response = await fetch(`${config.apiUrl}/update/check`, {
|
|
1029
|
+
method: 'POST',
|
|
1030
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1031
|
+
body: JSON.stringify({
|
|
1032
|
+
hash: currentHash,
|
|
1033
|
+
platform: os.platform(),
|
|
1034
|
+
arch: os.arch()
|
|
1035
|
+
})
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
if (!response.ok) {
|
|
1039
|
+
callback?.({ success: false, error: `HTTP ${response.status}` })
|
|
1040
|
+
return
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const info = await response.json() as any
|
|
1044
|
+
callback?.({
|
|
1045
|
+
success: true,
|
|
1046
|
+
updateAvailable: info.updateAvailable,
|
|
1047
|
+
version: info.version,
|
|
1048
|
+
changelog: info.changelog,
|
|
1049
|
+
size: info.size
|
|
1050
|
+
})
|
|
1051
|
+
} catch (error: any) {
|
|
1052
|
+
callback?.({ success: false, error: error.message })
|
|
1053
|
+
}
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
// Start update
|
|
1057
|
+
socket.on('update:start', async (data: {}, callback?: (response: {
|
|
1058
|
+
success: boolean
|
|
1059
|
+
message?: string
|
|
1060
|
+
error?: string
|
|
1061
|
+
}) => void) => {
|
|
1062
|
+
try {
|
|
1063
|
+
// Request update from parent updater process
|
|
1064
|
+
requestUpdateFromParent()
|
|
1065
|
+
|
|
1066
|
+
callback?.({
|
|
1067
|
+
success: true,
|
|
1068
|
+
message: 'Update initiated. The CLI will restart automatically when complete.'
|
|
1069
|
+
})
|
|
1070
|
+
} catch (error: any) {
|
|
1071
|
+
callback?.({ success: false, error: error.message })
|
|
1072
|
+
}
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
// Get version info
|
|
1076
|
+
socket.on('version:info', async (data: {}, callback?: (response: {
|
|
1077
|
+
success: boolean
|
|
1078
|
+
version?: string
|
|
1079
|
+
hash?: string
|
|
1080
|
+
date?: string
|
|
1081
|
+
nodeVersion?: string
|
|
1082
|
+
platform?: string
|
|
1083
|
+
arch?: string
|
|
1084
|
+
error?: string
|
|
1085
|
+
}) => void) => {
|
|
1086
|
+
try {
|
|
1087
|
+
const currentHash = createHash('sha256').update(fsSync.readFileSync(fileURLToPath(import.meta.url))).digest('hex')
|
|
1088
|
+
|
|
1089
|
+
// Try to read version from binary-hashes.json
|
|
1090
|
+
let version = 'unknown'
|
|
1091
|
+
let date = undefined
|
|
1092
|
+
try {
|
|
1093
|
+
const hashesPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'binary-hashes.json')
|
|
1094
|
+
const hashes = JSON.parse(fsSync.readFileSync(hashesPath, 'utf-8'))
|
|
1095
|
+
if (hashes['js-bundle']) {
|
|
1096
|
+
version = hashes['js-bundle'].version || version
|
|
1097
|
+
date = hashes['js-bundle'].date
|
|
1098
|
+
}
|
|
1099
|
+
} catch {}
|
|
1100
|
+
|
|
1101
|
+
callback?.({
|
|
1102
|
+
success: true,
|
|
1103
|
+
version,
|
|
1104
|
+
hash: currentHash.substring(0, 16) + '...',
|
|
1105
|
+
date,
|
|
1106
|
+
nodeVersion: process.version,
|
|
1107
|
+
platform: os.platform(),
|
|
1108
|
+
arch: os.arch()
|
|
1109
|
+
})
|
|
1110
|
+
} catch (error: any) {
|
|
1111
|
+
callback?.({ success: false, error: error.message })
|
|
1112
|
+
}
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
// Session handlers
|
|
1116
|
+
socket.on('session:create', async (data: { sessionId: string; projectId: string; projectPath: string }) => {
|
|
1117
|
+
try {
|
|
1118
|
+
let { sessionId, projectPath } = data
|
|
1119
|
+
|
|
1120
|
+
// Remap /home/abc to /tmp/abc if /home/abc doesn't exist (container workaround)
|
|
1121
|
+
if (projectPath === '/home/abc' && !fsSync.existsSync('/home/abc')) {
|
|
1122
|
+
const fallbackPath = '/tmp/abc'
|
|
1123
|
+
fsSync.mkdirSync(fallbackPath, { recursive: true })
|
|
1124
|
+
projectPath = fallbackPath
|
|
1125
|
+
console.log(`[CLI] Remapped /home/abc -> ${fallbackPath}`)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
activeSessions.set(sessionId, { projectPath })
|
|
1129
|
+
if (foreground) {
|
|
1130
|
+
console.log(`💬 Session created: ${sessionId}`)
|
|
1131
|
+
console.log(` Project: ${projectPath}`)
|
|
1132
|
+
} else {
|
|
1133
|
+
console.log(`Session created: ${sessionId} in project ${projectPath}`)
|
|
1134
|
+
}
|
|
1135
|
+
} catch (error: any) {
|
|
1136
|
+
if (foreground) {
|
|
1137
|
+
console.error(`✗ Failed to create session: ${error.message}`)
|
|
1138
|
+
} else {
|
|
1139
|
+
console.error('Failed to create session:', error.message)
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
socket.on('session:delete', async (data: { sessionId: string }) => {
|
|
1145
|
+
try {
|
|
1146
|
+
const { sessionId } = data
|
|
1147
|
+
if (foreground) {
|
|
1148
|
+
console.log(`🗑️ Deleting session: ${sessionId}`)
|
|
1149
|
+
}
|
|
1150
|
+
await agentSessionManager.deleteSession(sessionId)
|
|
1151
|
+
activeSessions.delete(sessionId)
|
|
1152
|
+
if (foreground) {
|
|
1153
|
+
console.log(`✓ Session deleted: ${sessionId}`)
|
|
1154
|
+
} else {
|
|
1155
|
+
console.log(`Session deleted: ${sessionId}`)
|
|
1156
|
+
}
|
|
1157
|
+
} catch (error: any) {
|
|
1158
|
+
if (foreground) {
|
|
1159
|
+
console.error(`✗ Failed to delete session: ${error.message}`)
|
|
1160
|
+
} else {
|
|
1161
|
+
console.error('Failed to delete session:', error.message)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
socket.on('project:config:analyze', async (data: { projectId: string; projectPath: string; projectName: string; analysisId?: string }) => {
|
|
1167
|
+
try {
|
|
1168
|
+
const { projectId, projectPath, projectName, analysisId } = data
|
|
1169
|
+
|
|
1170
|
+
if (foreground) {
|
|
1171
|
+
console.log(`🔍 Analyzing project: ${projectName} at ${projectPath}`)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
// Run Claude analysis
|
|
1176
|
+
const config = await analyzeProjectWithClaude(projectPath, projectName)
|
|
1177
|
+
|
|
1178
|
+
// Save config file (.talk-to-code.json)
|
|
1179
|
+
await saveProjectConfig(projectPath, config)
|
|
1180
|
+
|
|
1181
|
+
// Generate and save runner files for each app
|
|
1182
|
+
for (const app of config.apps) {
|
|
1183
|
+
const runnerCode = generateRunnerCode(app, projectPath)
|
|
1184
|
+
const runnerFileName = `${app.name}_runner.ts`
|
|
1185
|
+
const runnerPath = path.join(projectPath, app.directory || '', runnerFileName)
|
|
1186
|
+
|
|
1187
|
+
// Ensure directory exists
|
|
1188
|
+
const runnerDir = path.dirname(runnerPath)
|
|
1189
|
+
await fs.mkdir(runnerDir, { recursive: true })
|
|
1190
|
+
|
|
1191
|
+
// Write runner file
|
|
1192
|
+
await fs.writeFile(runnerPath, runnerCode, 'utf-8')
|
|
1193
|
+
|
|
1194
|
+
if (foreground) {
|
|
1195
|
+
console.log(`✓ Generated runner: ${runnerFileName}`)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (foreground) {
|
|
1200
|
+
console.log(`✓ Analysis complete: Found ${config.apps.length} apps`)
|
|
1201
|
+
console.log(`✓ Generated ${config.apps.length} runner files`)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Emit result back to backend
|
|
1205
|
+
socket!.emit('project:config:analyzed', {
|
|
1206
|
+
projectId,
|
|
1207
|
+
config,
|
|
1208
|
+
analysisId
|
|
1209
|
+
})
|
|
1210
|
+
} catch (error: any) {
|
|
1211
|
+
if (foreground) {
|
|
1212
|
+
console.error(`✗ Analysis error: ${error.message}`)
|
|
1213
|
+
}
|
|
1214
|
+
socket!.emit('project:config:analyze:error', {
|
|
1215
|
+
projectId: data.projectId,
|
|
1216
|
+
error: error.message,
|
|
1217
|
+
analysisId: data.analysisId
|
|
1218
|
+
})
|
|
1219
|
+
}
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
// App control handlers
|
|
1223
|
+
socket.on('app:start', async (data: { projectId: string; projectPath: string; appName: string; appControlId?: string }, callback: (response: { success: boolean; error?: string; processId?: string; pid?: number }) => void) => {
|
|
1224
|
+
try {
|
|
1225
|
+
const { projectId, projectPath, appName } = data
|
|
1226
|
+
|
|
1227
|
+
// Load project config to get app details
|
|
1228
|
+
const config = await getProjectConfig(projectPath)
|
|
1229
|
+
|
|
1230
|
+
if (!config) {
|
|
1231
|
+
callback?.({ success: false, error: 'Project config not found' })
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const app = config.apps.find(a => a.name === appName)
|
|
1236
|
+
if (!app) {
|
|
1237
|
+
callback?.({ success: false, error: `App "${appName}" not found in project config` })
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const result = await startApp(projectPath, projectId, app)
|
|
1242
|
+
|
|
1243
|
+
if (result.success) {
|
|
1244
|
+
if (foreground) {
|
|
1245
|
+
console.log(`✓ Started app: ${appName} (PID: ${result.pid})`)
|
|
1246
|
+
}
|
|
1247
|
+
socket!.emit('app:started', {
|
|
1248
|
+
projectId,
|
|
1249
|
+
appName,
|
|
1250
|
+
processId: result.processId,
|
|
1251
|
+
pid: result.pid,
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
callback?.(result)
|
|
1256
|
+
} catch (error: any) {
|
|
1257
|
+
if (foreground) {
|
|
1258
|
+
console.error(`✗ Error starting app: ${error.message}`)
|
|
1259
|
+
}
|
|
1260
|
+
callback?.({ success: false, error: error.message })
|
|
1261
|
+
}
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
socket.on('app:stop', async (data: { projectId: string; projectPath: string; appName: string; appControlId?: string }, callback: (response: { success: boolean; error?: string }) => void) => {
|
|
1265
|
+
try {
|
|
1266
|
+
const { projectId, projectPath, appName } = data
|
|
1267
|
+
|
|
1268
|
+
// Load project config to get app details (for custom stop command)
|
|
1269
|
+
const config = await getProjectConfig(projectPath)
|
|
1270
|
+
const app = config?.apps.find(a => a.name === appName)
|
|
1271
|
+
|
|
1272
|
+
const result = await stopApp(projectId, appName, app)
|
|
1273
|
+
|
|
1274
|
+
if (result.success) {
|
|
1275
|
+
if (foreground) {
|
|
1276
|
+
console.log(`✓ Stopped app: ${appName}`)
|
|
1277
|
+
}
|
|
1278
|
+
socket!.emit('app:stopped', {
|
|
1279
|
+
projectId,
|
|
1280
|
+
appName,
|
|
1281
|
+
appControlId: data.appControlId,
|
|
1282
|
+
})
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
callback?.(result)
|
|
1286
|
+
} catch (error: any) {
|
|
1287
|
+
if (foreground) {
|
|
1288
|
+
console.error(`✗ Error stopping app: ${error.message}`)
|
|
1289
|
+
}
|
|
1290
|
+
callback?.({ success: false, error: error.message })
|
|
1291
|
+
}
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
socket.on('app:restart', async (data: { projectId: string; projectPath: string; appName: string; appControlId?: string }, callback: (response: { success: boolean; error?: string; processId?: string; pid?: number }) => void) => {
|
|
1295
|
+
try {
|
|
1296
|
+
const { projectId, projectPath, appName } = data
|
|
1297
|
+
|
|
1298
|
+
// Load project config to get app details
|
|
1299
|
+
const config = await getProjectConfig(projectPath)
|
|
1300
|
+
|
|
1301
|
+
if (!config) {
|
|
1302
|
+
callback?.({ success: false, error: 'Project config not found' })
|
|
1303
|
+
return
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const app = config.apps.find(a => a.name === appName)
|
|
1307
|
+
if (!app) {
|
|
1308
|
+
callback?.({ success: false, error: `App "${appName}" not found in project config` })
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const result = await restartApp(projectPath, projectId, app)
|
|
1313
|
+
|
|
1314
|
+
if (result.success) {
|
|
1315
|
+
if (foreground) {
|
|
1316
|
+
console.log(`✓ Restarted app: ${appName} (PID: ${result.pid})`)
|
|
1317
|
+
}
|
|
1318
|
+
socket!.emit('app:restarted', {
|
|
1319
|
+
projectId,
|
|
1320
|
+
appName,
|
|
1321
|
+
processId: result.processId,
|
|
1322
|
+
pid: result.pid,
|
|
1323
|
+
appControlId: data.appControlId,
|
|
1324
|
+
})
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
callback?.(result)
|
|
1328
|
+
} catch (error: any) {
|
|
1329
|
+
if (foreground) {
|
|
1330
|
+
console.error(`✗ Error restarting app: ${error.message}`)
|
|
1331
|
+
}
|
|
1332
|
+
callback?.({ success: false, error: error.message })
|
|
1333
|
+
}
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
socket.on('app:status', async (data: { projectId: string; projectPath: string; appName?: string; appControlId?: string }, callback: (response: { success: boolean; apps?: Array<{ name: string; running: boolean; processId?: string; pid?: number }>; error?: string }) => void) => {
|
|
1337
|
+
try {
|
|
1338
|
+
const { projectId, projectPath, appName } = data
|
|
1339
|
+
|
|
1340
|
+
// Load project config
|
|
1341
|
+
const config = await getProjectConfig(projectPath)
|
|
1342
|
+
|
|
1343
|
+
if (!config) {
|
|
1344
|
+
callback?.({ success: false, error: 'Project config not found' })
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const appsToCheck = appName
|
|
1349
|
+
? config.apps.filter(a => a.name === appName)
|
|
1350
|
+
: config.apps
|
|
1351
|
+
|
|
1352
|
+
const statuses = getAppStatuses(projectId, appsToCheck)
|
|
1353
|
+
|
|
1354
|
+
// Send response back to backend
|
|
1355
|
+
if (data.appControlId) {
|
|
1356
|
+
socket!.emit('app:control:response', {
|
|
1357
|
+
appControlId: data.appControlId,
|
|
1358
|
+
success: true,
|
|
1359
|
+
apps: statuses,
|
|
1360
|
+
})
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
callback?.({ success: true, apps: statuses })
|
|
1364
|
+
} catch (error: any) {
|
|
1365
|
+
if (foreground) {
|
|
1366
|
+
console.error(`✗ Error getting app status: ${error.message}`)
|
|
1367
|
+
}
|
|
1368
|
+
callback?.({ success: false, error: error.message })
|
|
1369
|
+
}
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
socket.on('app:logs', async (data: { projectId: string; appName: string; follow?: boolean; appControlId?: string }, callback: (response: { success: boolean; logs?: string; error?: string }) => void) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const { projectId, appName } = data
|
|
1375
|
+
|
|
1376
|
+
const result = await getAppLogs(projectId, appName, 100)
|
|
1377
|
+
|
|
1378
|
+
// Send response back to backend
|
|
1379
|
+
if (data.appControlId) {
|
|
1380
|
+
socket!.emit('app:control:response', {
|
|
1381
|
+
appControlId: data.appControlId,
|
|
1382
|
+
...result,
|
|
1383
|
+
})
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
callback?.(result)
|
|
1387
|
+
} catch (error: any) {
|
|
1388
|
+
if (foreground) {
|
|
1389
|
+
console.error(`✗ Error getting app logs: ${error.message}`)
|
|
1390
|
+
}
|
|
1391
|
+
callback?.({ success: false, error: error.message })
|
|
1392
|
+
}
|
|
1393
|
+
})
|
|
1394
|
+
|
|
1395
|
+
socket.on('session:prompt', async (data: SessionPromptData & { projectPath?: string; promptId?: string; model?: string }) => {
|
|
1396
|
+
try {
|
|
1397
|
+
const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model } = data
|
|
1398
|
+
|
|
1399
|
+
// promptId is REQUIRED when routing to device
|
|
1400
|
+
if (!promptId) {
|
|
1401
|
+
if (foreground) {
|
|
1402
|
+
console.error(`✗ Missing required promptId for session: ${sessionId}`)
|
|
1403
|
+
}
|
|
1404
|
+
socket!.emit('session:error', { sessionId, error: 'Missing required promptId' })
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Try to get projectPath from activeSessions or use provided one
|
|
1409
|
+
let projectPath: string | undefined = providedProjectPath || activeSessions.get(sessionId)?.projectPath
|
|
1410
|
+
|
|
1411
|
+
if (!projectPath) {
|
|
1412
|
+
if (foreground) {
|
|
1413
|
+
console.error(`✗ Session not found: ${sessionId} (missing projectPath)`)
|
|
1414
|
+
}
|
|
1415
|
+
socket!.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' })
|
|
1416
|
+
return
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Check if user choice is enabled from the prompt data (sent by backend)
|
|
1420
|
+
// Backend now includes enabledModules in the session:prompt event
|
|
1421
|
+
const enabledModules = data.enabledModules || []
|
|
1422
|
+
const moduleSettings = data.moduleSettings || {}
|
|
1423
|
+
const userChoiceEnabled = enabledModules.includes('user-choice') || false
|
|
1424
|
+
|
|
1425
|
+
if (foreground) {
|
|
1426
|
+
console.log(`[CLI] Enabled modules: ${enabledModules.join(', ') || 'none'}`)
|
|
1427
|
+
console.log(`[CLI] User choice enabled: ${userChoiceEnabled}`)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Store in activeSessions with promptId and model
|
|
1431
|
+
activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model })
|
|
1432
|
+
|
|
1433
|
+
// Capture promptId in closure to prevent race conditions when multiple prompts arrive quickly
|
|
1434
|
+
// This ensures each prompt's messages use the correct promptId even if a new prompt arrives
|
|
1435
|
+
const capturedPromptId = promptId
|
|
1436
|
+
|
|
1437
|
+
if (foreground) {
|
|
1438
|
+
console.log(`\n[CLI] 📤 Received prompt for session: ${sessionId}, promptId: ${capturedPromptId}`)
|
|
1439
|
+
console.log(`[CLI] Prompt: ${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}`)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Create or get session handler
|
|
1443
|
+
await agentSessionManager.createSession({
|
|
1444
|
+
sessionId,
|
|
1445
|
+
projectPath,
|
|
1446
|
+
userChoiceEnabled, // Pass user choice enabled setting from backend
|
|
1447
|
+
enabledModules, // Pass enabled modules for MCP server
|
|
1448
|
+
moduleSettings, // Pass module settings for MCP server
|
|
1449
|
+
onOutput: (output: SessionOutput) => {
|
|
1450
|
+
// Serialize data to string if it's an object
|
|
1451
|
+
const dataString = typeof output.data === 'string'
|
|
1452
|
+
? output.data
|
|
1453
|
+
: JSON.stringify(output.data)
|
|
1454
|
+
|
|
1455
|
+
// Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
|
|
1456
|
+
if (!capturedPromptId) {
|
|
1457
|
+
console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`)
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
socket!.emit('prompt:output', {
|
|
1462
|
+
sessionId,
|
|
1463
|
+
promptId: capturedPromptId, // Use captured promptId, not currentPromptId
|
|
1464
|
+
type: output.type,
|
|
1465
|
+
data: dataString,
|
|
1466
|
+
timestamp: output.timestamp,
|
|
1467
|
+
metadata: output.metadata
|
|
1468
|
+
})
|
|
1469
|
+
},
|
|
1470
|
+
onError: (error: string) => {
|
|
1471
|
+
socket!.emit('session:error', { sessionId, error })
|
|
1472
|
+
},
|
|
1473
|
+
onComplete: (exitCode: number | null) => {
|
|
1474
|
+
// Note: This onComplete is from createSession, not sendPrompt
|
|
1475
|
+
// The actual session:result is emitted from sendPrompt handler below
|
|
1476
|
+
// This handler is kept for backward compatibility but may not be used
|
|
1477
|
+
},
|
|
1478
|
+
onChoiceRequest: (request) => {
|
|
1479
|
+
// Emit choice request to frontend
|
|
1480
|
+
socket!.emit('user:choice:request', {
|
|
1481
|
+
sessionId,
|
|
1482
|
+
choiceId: request.choiceId,
|
|
1483
|
+
question: request.question,
|
|
1484
|
+
options: request.options,
|
|
1485
|
+
timeout: request.timeout
|
|
1486
|
+
})
|
|
1487
|
+
}
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
// Send prompt - status updates will be emitted from agentSession when processing starts/completes
|
|
1491
|
+
await agentSessionManager.sendPrompt(sessionId, prompt, enhancers || [], {
|
|
1492
|
+
sessionId,
|
|
1493
|
+
projectPath,
|
|
1494
|
+
promptId: capturedPromptId,
|
|
1495
|
+
model: model, // Pass the model from the session
|
|
1496
|
+
onStatusUpdate: (status: 'running' | 'completed' | 'error' | 'cancelled') => {
|
|
1497
|
+
// Emit status update from CLI (CLI is source of truth)
|
|
1498
|
+
// Use captured promptId to ensure correct prompt is updated
|
|
1499
|
+
if (!capturedPromptId) {
|
|
1500
|
+
console.error(`[CLI] Missing promptId for status update, cannot emit prompt:updated`)
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Always log status updates for debugging (even in background mode)
|
|
1505
|
+
console.log(`[CLI] 📊 Status update: promptId=${capturedPromptId}, status=${status}, sessionId=${sessionId}`)
|
|
1506
|
+
|
|
1507
|
+
// Emit status update IMMEDIATELY (real-time)
|
|
1508
|
+
socket!.emit('prompt:updated', {
|
|
1509
|
+
promptId: capturedPromptId, // CRITICAL: Use captured promptId from closure
|
|
1510
|
+
sessionId,
|
|
1511
|
+
text: prompt,
|
|
1512
|
+
status,
|
|
1513
|
+
createdAt: new Date().toISOString(),
|
|
1514
|
+
...(status === 'running' ? { startedAt: new Date().toISOString() } : {}),
|
|
1515
|
+
...(status === 'completed' || status === 'error' || status === 'cancelled' ? { completedAt: new Date().toISOString() } : {}),
|
|
1516
|
+
messages: []
|
|
1517
|
+
})
|
|
1518
|
+
},
|
|
1519
|
+
onOutput: (output: SessionOutput) => {
|
|
1520
|
+
// Serialize data to string if it's an object
|
|
1521
|
+
const dataString = typeof output.data === 'string'
|
|
1522
|
+
? output.data
|
|
1523
|
+
: JSON.stringify(output.data)
|
|
1524
|
+
|
|
1525
|
+
// Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
|
|
1526
|
+
if (!capturedPromptId) {
|
|
1527
|
+
console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`)
|
|
1528
|
+
return
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (foreground && output.type === 'stdout') {
|
|
1532
|
+
process.stdout.write(dataString)
|
|
1533
|
+
}
|
|
1534
|
+
socket!.emit('prompt:output', {
|
|
1535
|
+
sessionId,
|
|
1536
|
+
promptId: capturedPromptId, // Use captured promptId, not currentPromptId
|
|
1537
|
+
type: output.type,
|
|
1538
|
+
data: dataString,
|
|
1539
|
+
timestamp: output.timestamp,
|
|
1540
|
+
metadata: output.metadata
|
|
1541
|
+
})
|
|
1542
|
+
},
|
|
1543
|
+
onError: (error: string) => {
|
|
1544
|
+
// Error logging is handled by AgentLogger in agentSession.ts
|
|
1545
|
+
// Additional console output for foreground mode
|
|
1546
|
+
if (foreground) {
|
|
1547
|
+
console.error(`\n[CLI] ✗ Session error: ${error}`)
|
|
1548
|
+
}
|
|
1549
|
+
socket!.emit('session:error', { sessionId, error })
|
|
1550
|
+
},
|
|
1551
|
+
onComplete: (exitCode: number | null) => {
|
|
1552
|
+
// Completion logging is handled by AgentLogger in agentSession.ts
|
|
1553
|
+
// Additional console output for foreground mode
|
|
1554
|
+
if (foreground) {
|
|
1555
|
+
console.log(`\n[CLI] ✓ Session completed with exit code: ${exitCode ?? 'null'}`)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
|
|
1559
|
+
if (!capturedPromptId) {
|
|
1560
|
+
console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit session:result`)
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
socket!.emit('session:result', {
|
|
1565
|
+
sessionId,
|
|
1566
|
+
promptId: capturedPromptId, // Use captured promptId, not currentPromptId
|
|
1567
|
+
exitCode
|
|
1568
|
+
})
|
|
1569
|
+
}
|
|
1570
|
+
})
|
|
1571
|
+
} catch (error: any) {
|
|
1572
|
+
if (foreground) {
|
|
1573
|
+
console.error(`✗ Error processing prompt: ${error.message}`)
|
|
1574
|
+
}
|
|
1575
|
+
socket!.emit('session:error', { sessionId: data.sessionId, error: error.message })
|
|
1576
|
+
}
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
// Handle prompt cancellation
|
|
1580
|
+
socket.on('prompt:cancel', async (data: { promptId: string; sessionId: string }, callback: (response: { success: boolean; error?: string }) => void) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const { promptId, sessionId } = data
|
|
1583
|
+
|
|
1584
|
+
if (!promptId || !sessionId) {
|
|
1585
|
+
callback?.({ success: false, error: 'Missing promptId or sessionId' })
|
|
1586
|
+
return
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (foreground) {
|
|
1590
|
+
console.log(`[CLI] 🛑 Cancelling prompt: ${promptId}`)
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// Cancel the prompt
|
|
1594
|
+
const cancelled = await agentSessionManager.cancelPrompt(
|
|
1595
|
+
promptId,
|
|
1596
|
+
sessionId,
|
|
1597
|
+
(status) => {
|
|
1598
|
+
// Emit cancelled status update
|
|
1599
|
+
socket!.emit('prompt:updated', {
|
|
1600
|
+
promptId,
|
|
1601
|
+
sessionId,
|
|
1602
|
+
text: '', // Not needed for status update
|
|
1603
|
+
status: 'cancelled',
|
|
1604
|
+
createdAt: new Date().toISOString(),
|
|
1605
|
+
completedAt: new Date().toISOString(),
|
|
1606
|
+
messages: []
|
|
1607
|
+
})
|
|
1608
|
+
}
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
if (cancelled) {
|
|
1612
|
+
callback?.({ success: true })
|
|
1613
|
+
} else {
|
|
1614
|
+
callback?.({ success: false, error: 'Prompt not found or already completed' })
|
|
1615
|
+
}
|
|
1616
|
+
} catch (error: any) {
|
|
1617
|
+
if (foreground) {
|
|
1618
|
+
console.error(`✗ Error cancelling prompt: ${error.message}`)
|
|
1619
|
+
}
|
|
1620
|
+
callback?.({ success: false, error: error.message })
|
|
1621
|
+
}
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
// Handle emergency stop - forcefully halt all session activity
|
|
1625
|
+
socket.on('emergency:stop', async (data: { sessionId: string }, callback: (response: { success: boolean; message?: string }) => void) => {
|
|
1626
|
+
try {
|
|
1627
|
+
const { sessionId } = data
|
|
1628
|
+
|
|
1629
|
+
if (!sessionId) {
|
|
1630
|
+
callback?.({ success: false, message: 'Missing sessionId' })
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (foreground) {
|
|
1635
|
+
console.log(`[CLI] ☠️ EMERGENCY STOP for session: ${sessionId}`)
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Execute emergency stop
|
|
1639
|
+
const result = await agentSessionManager.emergencyStop(sessionId)
|
|
1640
|
+
|
|
1641
|
+
// Emit status update for current prompt if any
|
|
1642
|
+
socket!.emit('emergency:stopped', {
|
|
1643
|
+
sessionId,
|
|
1644
|
+
success: result.success,
|
|
1645
|
+
message: result.message,
|
|
1646
|
+
timestamp: new Date().toISOString()
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
callback?.(result)
|
|
1650
|
+
} catch (error: any) {
|
|
1651
|
+
if (foreground) {
|
|
1652
|
+
console.error(`✗ Error during emergency stop: ${error.message}`)
|
|
1653
|
+
}
|
|
1654
|
+
callback?.({ success: false, message: error.message })
|
|
1655
|
+
}
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
// Handle user choice response from frontend
|
|
1659
|
+
socket.on('user:choice:response', async (data: { sessionId: string; choiceId: string; selectedValue: string | null }) => {
|
|
1660
|
+
try {
|
|
1661
|
+
const { sessionId, choiceId, selectedValue } = data
|
|
1662
|
+
|
|
1663
|
+
if (foreground) {
|
|
1664
|
+
console.log(`[CLI] 📝 Received user choice response: choiceId=${choiceId}, selectedValue=${selectedValue}`)
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Forward the response to the agent session manager
|
|
1668
|
+
await agentSessionManager.handleChoiceResponse(sessionId, {
|
|
1669
|
+
choiceId,
|
|
1670
|
+
selectedValue
|
|
1671
|
+
})
|
|
1672
|
+
} catch (error: any) {
|
|
1673
|
+
if (foreground) {
|
|
1674
|
+
console.error(`✗ Error handling user choice response: ${error.message}`)
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
socket.on('connect', () => {
|
|
1680
|
+
if (foreground) {
|
|
1681
|
+
console.log(`✓ Connected to backend at ${config.apiUrl}`)
|
|
1682
|
+
if (socket) {
|
|
1683
|
+
console.log(` Socket ID: ${socket.id}`)
|
|
1684
|
+
}
|
|
1685
|
+
} else {
|
|
1686
|
+
console.log('Connected to backend')
|
|
1687
|
+
}
|
|
1688
|
+
reconnectAttempts = 0
|
|
1689
|
+
|
|
1690
|
+
// Register as CLI device
|
|
1691
|
+
if (socket) {
|
|
1692
|
+
socket.emit('register', { type: 'cli', deviceId })
|
|
1693
|
+
}
|
|
1694
|
+
})
|
|
1695
|
+
|
|
1696
|
+
socket.on('registered', async ({ type }: { type: string }) => {
|
|
1697
|
+
if (foreground) {
|
|
1698
|
+
console.log(`✓ Registered as ${type}`)
|
|
1699
|
+
} else {
|
|
1700
|
+
console.log(`Registered as ${type}`)
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
|
|
1704
|
+
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
1705
|
+
let authToken = process.env.TTC_AUTH_TOKEN
|
|
1706
|
+
let deviceEmail: string | undefined = email
|
|
1707
|
+
let skipEmailFlow = false
|
|
1708
|
+
|
|
1709
|
+
// Load from device-config if no env token (reuse state from previous approval)
|
|
1710
|
+
if (!authToken) {
|
|
1711
|
+
try {
|
|
1712
|
+
const deviceConfig = JSON.parse(await fs.readFile(deviceConfigFile, 'utf-8'))
|
|
1713
|
+
authToken = deviceConfig.authToken
|
|
1714
|
+
if (!deviceEmail && deviceConfig.email) deviceEmail = deviceConfig.email
|
|
1715
|
+
} catch {
|
|
1716
|
+
// No device config yet
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (authToken) {
|
|
1721
|
+
try {
|
|
1722
|
+
const parts = authToken.split('.')
|
|
1723
|
+
if (parts.length === 3) {
|
|
1724
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString())
|
|
1725
|
+
if (payload.type === 'device_auth' && payload.email) {
|
|
1726
|
+
deviceEmail = payload.email
|
|
1727
|
+
skipEmailFlow = true
|
|
1728
|
+
if (foreground) {
|
|
1729
|
+
console.log(`✓ Using stored auth token for ${deviceEmail}`)
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
} catch (err) {
|
|
1734
|
+
if (foreground) {
|
|
1735
|
+
console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`)
|
|
1736
|
+
}
|
|
1737
|
+
authToken = undefined
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Load device email from config if not using token and not provided
|
|
1742
|
+
if (!deviceEmail && !skipEmailFlow) {
|
|
1743
|
+
try {
|
|
1744
|
+
const deviceConfig = JSON.parse(await fs.readFile(deviceConfigFile, 'utf-8'))
|
|
1745
|
+
deviceEmail = deviceConfig.email
|
|
1746
|
+
} catch {
|
|
1747
|
+
// No device config yet
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Register device with backend
|
|
1752
|
+
let needsApprovalLoggedOnce = false
|
|
1753
|
+
const registerDevice = () => {
|
|
1754
|
+
// Determine device name
|
|
1755
|
+
const containerName = process.env.CONTAINER_NAME
|
|
1756
|
+
const deviceName = containerName ? `Container: ${containerName}` : `CLI Device ${hostname}`
|
|
1757
|
+
|
|
1758
|
+
socket!.emit('device:register', {
|
|
1759
|
+
deviceId,
|
|
1760
|
+
name: deviceName,
|
|
1761
|
+
ipAddress,
|
|
1762
|
+
hostname,
|
|
1763
|
+
email: deviceEmail,
|
|
1764
|
+
// When using auth token, include the token for verification
|
|
1765
|
+
authToken: skipEmailFlow ? authToken : undefined
|
|
1766
|
+
}, ({ success, needsApproval, message, device, error }: {
|
|
1767
|
+
success: boolean
|
|
1768
|
+
needsApproval?: boolean
|
|
1769
|
+
message?: string
|
|
1770
|
+
device?: Device
|
|
1771
|
+
error?: string
|
|
1772
|
+
}) => {
|
|
1773
|
+
if (success && device) {
|
|
1774
|
+
needsApprovalLoggedOnce = false
|
|
1775
|
+
if (foreground) {
|
|
1776
|
+
console.log(`✓ Device registered: ${device.name}`)
|
|
1777
|
+
if (device.approved) {
|
|
1778
|
+
console.log(`✓ Device approved and ready`)
|
|
1779
|
+
}
|
|
1780
|
+
} else {
|
|
1781
|
+
console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`)
|
|
1782
|
+
}
|
|
1783
|
+
// Save config: email + authToken (preserve token so next run can reuse)
|
|
1784
|
+
if (device.email) {
|
|
1785
|
+
const toSave = { email: device.email, ...(authToken ? { authToken } : {}) }
|
|
1786
|
+
fs.writeFile(deviceConfigFile, JSON.stringify(toSave, null, 2)).catch(() => {})
|
|
1787
|
+
}
|
|
1788
|
+
} else if (needsApproval) {
|
|
1789
|
+
// Persist email when approval was requested so restarts don't prompt again
|
|
1790
|
+
if (deviceEmail) {
|
|
1791
|
+
fs.writeFile(deviceConfigFile, JSON.stringify({ email: deviceEmail }, null, 2)).catch(() => {})
|
|
1792
|
+
}
|
|
1793
|
+
// Only log once to avoid flooding logs every 30s on heartbeat
|
|
1794
|
+
if (!needsApprovalLoggedOnce) {
|
|
1795
|
+
needsApprovalLoggedOnce = true
|
|
1796
|
+
if (foreground) {
|
|
1797
|
+
console.log(`\n⚠️ ${message || 'Device approval required'}`)
|
|
1798
|
+
if (!deviceEmail) {
|
|
1799
|
+
console.log(` Please run: npx tsx index.ts register --email your@email.com`)
|
|
1800
|
+
} else {
|
|
1801
|
+
console.log(` Check your email (${deviceEmail}) and click the approval link.`)
|
|
1802
|
+
}
|
|
1803
|
+
} else {
|
|
1804
|
+
console.log(`⚠️ Device approval required: ${message || 'Please approve device'}`)
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
} else if (error) {
|
|
1808
|
+
console.error(`✗ Registration error: ${error}`)
|
|
1809
|
+
}
|
|
1810
|
+
})
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
registerDevice()
|
|
1814
|
+
|
|
1815
|
+
// Update lastSeen every 30 seconds while connected
|
|
1816
|
+
const heartbeatInterval = setInterval(() => {
|
|
1817
|
+
if (socket?.connected) {
|
|
1818
|
+
registerDevice()
|
|
1819
|
+
} else {
|
|
1820
|
+
clearInterval(heartbeatInterval)
|
|
1821
|
+
}
|
|
1822
|
+
}, 30000)
|
|
1823
|
+
|
|
1824
|
+
// Store interval ID for cleanup
|
|
1825
|
+
;(socket as any).heartbeatInterval = heartbeatInterval
|
|
1826
|
+
})
|
|
1827
|
+
|
|
1828
|
+
// Cloudflared handlers
|
|
1829
|
+
socket.on('cloudflared:check:request', async () => {
|
|
1830
|
+
try {
|
|
1831
|
+
let installed = false
|
|
1832
|
+
let hasCert = false
|
|
1833
|
+
|
|
1834
|
+
try {
|
|
1835
|
+
// Check if cloudflared is installed
|
|
1836
|
+
execSync('which cloudflared', { stdio: 'ignore' })
|
|
1837
|
+
installed = true
|
|
1838
|
+
|
|
1839
|
+
// Check if cert.pem exists
|
|
1840
|
+
const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
|
|
1841
|
+
try {
|
|
1842
|
+
// Use fs.stat to check if file exists (more reliable than access)
|
|
1843
|
+
const stats = await fs.stat(certPath)
|
|
1844
|
+
hasCert = stats.isFile()
|
|
1845
|
+
if (foreground && hasCert) {
|
|
1846
|
+
console.log(`✓ Found cert.pem at ${certPath}`)
|
|
1847
|
+
}
|
|
1848
|
+
} catch (err: any) {
|
|
1849
|
+
// File doesn't exist or can't be accessed
|
|
1850
|
+
hasCert = false
|
|
1851
|
+
if (foreground) {
|
|
1852
|
+
console.log(`✗ cert.pem not found at ${certPath}: ${err.message}`)
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
} catch {
|
|
1856
|
+
installed = false
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
socket!.emit('cloudflared:check:response', { installed, hasCert })
|
|
1860
|
+
if (foreground) {
|
|
1861
|
+
console.log(`Cloudflared check: installed=${installed}, hasCert=${hasCert}`)
|
|
1862
|
+
}
|
|
1863
|
+
} catch (error: any) {
|
|
1864
|
+
socket!.emit('cloudflared:check:response', { installed: false, hasCert: false })
|
|
1865
|
+
}
|
|
1866
|
+
})
|
|
1867
|
+
|
|
1868
|
+
socket.on('cloudflared:sync:request', async () => {
|
|
1869
|
+
try {
|
|
1870
|
+
const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
|
|
1871
|
+
|
|
1872
|
+
if (foreground) {
|
|
1873
|
+
console.log(`Syncing credentials from ${certPath}`)
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Read cert.pem file
|
|
1877
|
+
const certContent = await fs.readFile(certPath, 'utf-8')
|
|
1878
|
+
|
|
1879
|
+
// Extract token between BEGIN and END markers
|
|
1880
|
+
const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
|
|
1881
|
+
if (tokenMatch && tokenMatch[1]) {
|
|
1882
|
+
// Decode base64 token
|
|
1883
|
+
const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
|
|
1884
|
+
const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
|
|
1885
|
+
const tokenData = JSON.parse(tokenJson)
|
|
1886
|
+
|
|
1887
|
+
// Extract API token, account ID, and zone ID
|
|
1888
|
+
const apiToken = tokenData.apiToken
|
|
1889
|
+
const accountId = tokenData.accountID
|
|
1890
|
+
const zoneId = tokenData.zoneID
|
|
1891
|
+
|
|
1892
|
+
if (foreground) {
|
|
1893
|
+
console.log(`✓ Extracted credentials: accountId=${accountId}, zoneId=${zoneId}`)
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
socket!.emit('cloudflared:sync:complete', {
|
|
1897
|
+
accountId,
|
|
1898
|
+
accountName: undefined,
|
|
1899
|
+
apiToken,
|
|
1900
|
+
zoneId
|
|
1901
|
+
})
|
|
1902
|
+
} else {
|
|
1903
|
+
const error = 'Failed to extract token from cert.pem'
|
|
1904
|
+
if (foreground) {
|
|
1905
|
+
console.error(`✗ ${error}`)
|
|
1906
|
+
}
|
|
1907
|
+
socket!.emit('cloudflared:sync:error', { error })
|
|
1908
|
+
}
|
|
1909
|
+
} catch (error: any) {
|
|
1910
|
+
const errorMsg = `Failed to read cert.pem: ${error.message}`
|
|
1911
|
+
if (foreground) {
|
|
1912
|
+
console.error(`✗ ${errorMsg}`)
|
|
1913
|
+
}
|
|
1914
|
+
socket!.emit('cloudflared:sync:error', { error: errorMsg })
|
|
1915
|
+
}
|
|
1916
|
+
})
|
|
1917
|
+
|
|
1918
|
+
socket.on('cloudflared:login:request', async () => {
|
|
1919
|
+
try {
|
|
1920
|
+
|
|
1921
|
+
// Run cloudflared tunnel login
|
|
1922
|
+
const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
|
|
1923
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1924
|
+
})
|
|
1925
|
+
|
|
1926
|
+
let stdout = ''
|
|
1927
|
+
let stderr = ''
|
|
1928
|
+
let urlEmitted = false
|
|
1929
|
+
let alreadyLoggedIn = false
|
|
1930
|
+
let certPath: string | null = null
|
|
1931
|
+
|
|
1932
|
+
const extractCertPath = (text: string): string | null => {
|
|
1933
|
+
// Look for: "You have an existing certificate at /path/to/cert.pem"
|
|
1934
|
+
const pathMatch = text.match(/existing certificate at\s+([^\s]+)/i) ||
|
|
1935
|
+
text.match(/certificate at\s+([^\s]+)/i) ||
|
|
1936
|
+
text.match(/cert\.pem.*?at\s+([^\s]+)/i)
|
|
1937
|
+
return pathMatch ? pathMatch[1] : null
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const extractLoginUrl = (text: string): string | null => {
|
|
1941
|
+
// Look for URLs in the output
|
|
1942
|
+
const urlPatterns = [
|
|
1943
|
+
/https:\/\/dash\.cloudflare\.com\/argotunnel[^\s\)]+/g,
|
|
1944
|
+
/https:\/\/[^\s\)]+cloudflareaccess\.org[^\s\)]+/g,
|
|
1945
|
+
/https:\/\/[^\s\)]+cloudflare\.com[^\s\)]+/g
|
|
1946
|
+
]
|
|
1947
|
+
|
|
1948
|
+
for (const pattern of urlPatterns) {
|
|
1949
|
+
const matches = text.match(pattern)
|
|
1950
|
+
if (matches && matches.length > 0) {
|
|
1951
|
+
return matches[0]
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return null
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
loginProcess.stdout.on('data', (data) => {
|
|
1958
|
+
const text = data.toString()
|
|
1959
|
+
stdout += text
|
|
1960
|
+
|
|
1961
|
+
// Check for already logged in error
|
|
1962
|
+
if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
|
|
1963
|
+
alreadyLoggedIn = true
|
|
1964
|
+
const extractedPath = extractCertPath(text)
|
|
1965
|
+
if (extractedPath) {
|
|
1966
|
+
certPath = extractedPath
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// Extract login URL if not already logged in
|
|
1971
|
+
if (!alreadyLoggedIn && !urlEmitted) {
|
|
1972
|
+
const url = extractLoginUrl(text)
|
|
1973
|
+
if (url) {
|
|
1974
|
+
urlEmitted = true
|
|
1975
|
+
socket!.emit('cloudflared:login:url', { loginUrl: url })
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
})
|
|
1979
|
+
|
|
1980
|
+
loginProcess.stderr.on('data', (data) => {
|
|
1981
|
+
const text = data.toString()
|
|
1982
|
+
stderr += text
|
|
1983
|
+
|
|
1984
|
+
// Check for already logged in error (often in stderr)
|
|
1985
|
+
if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
|
|
1986
|
+
alreadyLoggedIn = true
|
|
1987
|
+
const extractedPath = extractCertPath(text)
|
|
1988
|
+
if (extractedPath) {
|
|
1989
|
+
certPath = extractedPath
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Extract login URL if not already logged in
|
|
1994
|
+
if (!alreadyLoggedIn && !urlEmitted) {
|
|
1995
|
+
const url = extractLoginUrl(text)
|
|
1996
|
+
if (url) {
|
|
1997
|
+
urlEmitted = true
|
|
1998
|
+
socket!.emit('cloudflared:login:url', { loginUrl: url })
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
})
|
|
2002
|
+
|
|
2003
|
+
loginProcess.on('close', async (code) => {
|
|
2004
|
+
if (alreadyLoggedIn && certPath) {
|
|
2005
|
+
// Already logged in - extract credentials from existing cert
|
|
2006
|
+
try {
|
|
2007
|
+
if (foreground) {
|
|
2008
|
+
console.log(`Already logged in, extracting credentials from ${certPath}`)
|
|
2009
|
+
}
|
|
2010
|
+
const certContent = await fs.readFile(certPath, 'utf-8')
|
|
2011
|
+
const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
|
|
2012
|
+
if (tokenMatch && tokenMatch[1]) {
|
|
2013
|
+
const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
|
|
2014
|
+
const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
|
|
2015
|
+
const tokenData = JSON.parse(tokenJson)
|
|
2016
|
+
|
|
2017
|
+
if (foreground) {
|
|
2018
|
+
console.log(`✓ Extracted credentials from existing cert`)
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
socket!.emit('cloudflared:login:complete', {
|
|
2022
|
+
accountId: tokenData.accountID,
|
|
2023
|
+
accountName: undefined,
|
|
2024
|
+
apiToken: tokenData.apiToken,
|
|
2025
|
+
zoneId: tokenData.zoneID
|
|
2026
|
+
})
|
|
2027
|
+
} else {
|
|
2028
|
+
const error = 'Failed to extract token from existing cert.pem'
|
|
2029
|
+
if (foreground) {
|
|
2030
|
+
console.error(`✗ ${error}`)
|
|
2031
|
+
}
|
|
2032
|
+
socket!.emit('cloudflared:login:error', { error })
|
|
2033
|
+
}
|
|
2034
|
+
} catch (error: any) {
|
|
2035
|
+
const errorMsg = `Failed to read cert.pem: ${error.message}`
|
|
2036
|
+
if (foreground) {
|
|
2037
|
+
console.error(`✗ ${errorMsg}`)
|
|
2038
|
+
}
|
|
2039
|
+
socket!.emit('cloudflared:login:error', { error: errorMsg })
|
|
2040
|
+
}
|
|
2041
|
+
} else if (code === 0 && !alreadyLoggedIn) {
|
|
2042
|
+
// Login completed successfully - wait a moment then extract credentials
|
|
2043
|
+
if (foreground) {
|
|
2044
|
+
console.log('Login completed, extracting credentials...')
|
|
2045
|
+
}
|
|
2046
|
+
setTimeout(async () => {
|
|
2047
|
+
try {
|
|
2048
|
+
const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
|
|
2049
|
+
const certContent = await fs.readFile(certPath, 'utf-8')
|
|
2050
|
+
const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
|
|
2051
|
+
if (tokenMatch && tokenMatch[1]) {
|
|
2052
|
+
const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
|
|
2053
|
+
const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
|
|
2054
|
+
const tokenData = JSON.parse(tokenJson)
|
|
2055
|
+
|
|
2056
|
+
if (foreground) {
|
|
2057
|
+
console.log(`✓ Extracted credentials after login`)
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
socket!.emit('cloudflared:login:complete', {
|
|
2061
|
+
accountId: tokenData.accountID,
|
|
2062
|
+
accountName: undefined,
|
|
2063
|
+
apiToken: tokenData.apiToken,
|
|
2064
|
+
zoneId: tokenData.zoneID
|
|
2065
|
+
})
|
|
2066
|
+
} else {
|
|
2067
|
+
const error = 'Failed to extract token from cert.pem after login'
|
|
2068
|
+
if (foreground) {
|
|
2069
|
+
console.error(`✗ ${error}`)
|
|
2070
|
+
}
|
|
2071
|
+
socket!.emit('cloudflared:login:error', { error })
|
|
2072
|
+
}
|
|
2073
|
+
} catch (error: any) {
|
|
2074
|
+
const errorMsg = `Failed to read cert.pem after login: ${error.message}`
|
|
2075
|
+
if (foreground) {
|
|
2076
|
+
console.error(`✗ ${errorMsg}`)
|
|
2077
|
+
}
|
|
2078
|
+
socket!.emit('cloudflared:login:error', { error: errorMsg })
|
|
2079
|
+
}
|
|
2080
|
+
}, 1000) // Wait 1 second for file to be written
|
|
2081
|
+
} else if (!alreadyLoggedIn) {
|
|
2082
|
+
const error = `Login failed with code ${code}: ${stderr || stdout}`
|
|
2083
|
+
if (foreground) {
|
|
2084
|
+
console.error(`✗ ${error}`)
|
|
2085
|
+
}
|
|
2086
|
+
socket!.emit('cloudflared:login:error', { error })
|
|
2087
|
+
}
|
|
2088
|
+
})
|
|
2089
|
+
|
|
2090
|
+
loginProcess.on('error', (error) => {
|
|
2091
|
+
socket!.emit('cloudflared:login:error', { error: error.message })
|
|
2092
|
+
})
|
|
2093
|
+
} catch (error: any) {
|
|
2094
|
+
socket!.emit('cloudflared:login:error', { error: error.message })
|
|
2095
|
+
}
|
|
2096
|
+
})
|
|
2097
|
+
|
|
2098
|
+
socket.on('cloudflared:regenerate:request', async () => {
|
|
2099
|
+
try {
|
|
2100
|
+
const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
|
|
2101
|
+
|
|
2102
|
+
// Delete existing cert.pem
|
|
2103
|
+
try {
|
|
2104
|
+
await fs.unlink(certPath)
|
|
2105
|
+
if (foreground) {
|
|
2106
|
+
console.log(`✓ Deleted existing cert.pem`)
|
|
2107
|
+
}
|
|
2108
|
+
} catch (error: any) {
|
|
2109
|
+
// File might not exist, that's okay
|
|
2110
|
+
if (foreground && error.code !== 'ENOENT') {
|
|
2111
|
+
console.log(`Note: Could not delete cert.pem: ${error.message}`)
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Emit success - frontend will then trigger login
|
|
2116
|
+
socket!.emit('cloudflared:regenerate:complete', {})
|
|
2117
|
+
} catch (error: any) {
|
|
2118
|
+
socket!.emit('cloudflared:regenerate:error', { error: error.message })
|
|
2119
|
+
}
|
|
2120
|
+
})
|
|
2121
|
+
|
|
2122
|
+
socket.on('cloudflared:login:request', async () => {
|
|
2123
|
+
try {
|
|
2124
|
+
|
|
2125
|
+
// First check if cloudflared is installed
|
|
2126
|
+
try {
|
|
2127
|
+
execSync('which cloudflared', { stdio: 'ignore' })
|
|
2128
|
+
} catch {
|
|
2129
|
+
socket!.emit('cloudflared:login:error', { error: 'cloudflared is not installed' })
|
|
2130
|
+
return
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// Run cloudflared tunnel login
|
|
2134
|
+
// This command outputs a URL that needs to be visited
|
|
2135
|
+
const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
|
|
2136
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
2137
|
+
})
|
|
2138
|
+
|
|
2139
|
+
let stdout = ''
|
|
2140
|
+
let stderr = ''
|
|
2141
|
+
let urlEmitted = false
|
|
2142
|
+
|
|
2143
|
+
const findAndEmitUrl = (text: string) => {
|
|
2144
|
+
if (urlEmitted) return
|
|
2145
|
+
|
|
2146
|
+
// Look for URL in output - cloudflared outputs URLs in various formats
|
|
2147
|
+
const urlPatterns = [
|
|
2148
|
+
/https:\/\/[^\s\)]+/g,
|
|
2149
|
+
/https:\/\/[^\n]+/g
|
|
2150
|
+
]
|
|
2151
|
+
|
|
2152
|
+
for (const pattern of urlPatterns) {
|
|
2153
|
+
const matches = text.match(pattern)
|
|
2154
|
+
if (matches && matches.length > 0) {
|
|
2155
|
+
// Find the login URL (usually contains "cloudflareaccess.com" or similar)
|
|
2156
|
+
const loginUrl = matches.find(url =>
|
|
2157
|
+
url.includes('cloudflareaccess.com') ||
|
|
2158
|
+
url.includes('cloudflare.com') ||
|
|
2159
|
+
url.includes('trycloudflare.com') ||
|
|
2160
|
+
url.includes('dash.cloudflare.com')
|
|
2161
|
+
)
|
|
2162
|
+
if (loginUrl) {
|
|
2163
|
+
urlEmitted = true
|
|
2164
|
+
socket!.emit('cloudflared:login:url', { loginUrl: loginUrl.trim() })
|
|
2165
|
+
return
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
loginProcess.stdout.on('data', (data) => {
|
|
2172
|
+
const text = data.toString()
|
|
2173
|
+
stdout += text
|
|
2174
|
+
findAndEmitUrl(text)
|
|
2175
|
+
})
|
|
2176
|
+
|
|
2177
|
+
loginProcess.stderr.on('data', (data) => {
|
|
2178
|
+
const text = data.toString()
|
|
2179
|
+
stderr += text
|
|
2180
|
+
// cloudflared often outputs URLs to stderr
|
|
2181
|
+
findAndEmitUrl(text)
|
|
2182
|
+
})
|
|
2183
|
+
|
|
2184
|
+
loginProcess.on('close', async (code) => {
|
|
2185
|
+
if (code === 0) {
|
|
2186
|
+
// Login successful, extract API token from cert.pem
|
|
2187
|
+
try {
|
|
2188
|
+
const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
|
|
2189
|
+
const certContent = await fs.readFile(certPath, 'utf-8')
|
|
2190
|
+
|
|
2191
|
+
// Extract token between BEGIN and END markers
|
|
2192
|
+
const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
|
|
2193
|
+
if (tokenMatch && tokenMatch[1]) {
|
|
2194
|
+
// Decode base64 token
|
|
2195
|
+
const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
|
|
2196
|
+
const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
|
|
2197
|
+
const tokenData = JSON.parse(tokenJson)
|
|
2198
|
+
|
|
2199
|
+
// Extract API token, account ID, and zone ID
|
|
2200
|
+
const apiToken = tokenData.apiToken
|
|
2201
|
+
const accountId = tokenData.accountID
|
|
2202
|
+
const zoneId = tokenData.zoneID
|
|
2203
|
+
|
|
2204
|
+
socket!.emit('cloudflared:login:complete', {
|
|
2205
|
+
accountId,
|
|
2206
|
+
accountName: undefined,
|
|
2207
|
+
apiToken,
|
|
2208
|
+
zoneId
|
|
2209
|
+
})
|
|
2210
|
+
} else {
|
|
2211
|
+
socket!.emit('cloudflared:login:error', {
|
|
2212
|
+
error: 'Failed to extract token from cert.pem'
|
|
2213
|
+
})
|
|
2214
|
+
}
|
|
2215
|
+
} catch (error: any) {
|
|
2216
|
+
socket!.emit('cloudflared:login:error', {
|
|
2217
|
+
error: `Failed to read cert.pem: ${error.message}`
|
|
2218
|
+
})
|
|
2219
|
+
}
|
|
2220
|
+
} else {
|
|
2221
|
+
socket!.emit('cloudflared:login:error', {
|
|
2222
|
+
error: `Login failed with code ${code}: ${stderr}`
|
|
2223
|
+
})
|
|
2224
|
+
}
|
|
2225
|
+
})
|
|
2226
|
+
|
|
2227
|
+
loginProcess.on('error', (error) => {
|
|
2228
|
+
socket!.emit('cloudflared:login:error', { error: error.message })
|
|
2229
|
+
})
|
|
2230
|
+
} catch (error: any) {
|
|
2231
|
+
socket!.emit('cloudflared:login:error', { error: error.message })
|
|
2232
|
+
}
|
|
2233
|
+
})
|
|
2234
|
+
|
|
2235
|
+
// ========== Container Management Handlers ==========
|
|
2236
|
+
|
|
2237
|
+
// Check if container runtime is available
|
|
2238
|
+
socket.on('container:check:request', async () => {
|
|
2239
|
+
try {
|
|
2240
|
+
let enabled = false
|
|
2241
|
+
let runtime: 'docker' | 'podman' | undefined
|
|
2242
|
+
let version = ''
|
|
2243
|
+
|
|
2244
|
+
try {
|
|
2245
|
+
// Check for docker first
|
|
2246
|
+
execSync('which docker', { stdio: 'ignore' })
|
|
2247
|
+
runtime = 'docker'
|
|
2248
|
+
version = execSync('docker --version', { encoding: 'utf-8' }).trim()
|
|
2249
|
+
enabled = true
|
|
2250
|
+
} catch {
|
|
2251
|
+
try {
|
|
2252
|
+
// Check for podman as fallback
|
|
2253
|
+
execSync('which podman', { stdio: 'ignore' })
|
|
2254
|
+
runtime = 'podman'
|
|
2255
|
+
version = execSync('podman --version', { encoding: 'utf-8' }).trim()
|
|
2256
|
+
enabled = true
|
|
2257
|
+
} catch {
|
|
2258
|
+
enabled = false
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
socket!.emit('container:check:response', { enabled, runtime, version })
|
|
2263
|
+
if (foreground) {
|
|
2264
|
+
console.log(`Container runtime check: ${enabled ? `${runtime} (${version})` : 'Not found'}`)
|
|
2265
|
+
}
|
|
2266
|
+
} catch (error: any) {
|
|
2267
|
+
socket!.emit('container:check:response', { enabled: false, runtime: undefined, version: '' })
|
|
2268
|
+
}
|
|
2269
|
+
})
|
|
2270
|
+
|
|
2271
|
+
// List all containers
|
|
2272
|
+
socket.on('container:list:request', async () => {
|
|
2273
|
+
try {
|
|
2274
|
+
const runtime = getContainerRuntime()
|
|
2275
|
+
if (!runtime) {
|
|
2276
|
+
socket!.emit('container:list:response', { success: false, error: 'No container runtime found' })
|
|
2277
|
+
return
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// List all containers including stopped ones
|
|
2281
|
+
const output = execSync(
|
|
2282
|
+
`${runtime} ps -a --format "{{json .}}"`,
|
|
2283
|
+
{ encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
|
|
2284
|
+
)
|
|
2285
|
+
|
|
2286
|
+
const containers = output.trim().split('\n').filter(Boolean).map(line => {
|
|
2287
|
+
try {
|
|
2288
|
+
const c = JSON.parse(line)
|
|
2289
|
+
// Parse ports from the PORTS column (format: "0.0.0.0:8080->80/tcp, 0.0.0.0:9090->9090/tcp")
|
|
2290
|
+
const ports: Array<{ host: number; container: number }> = []
|
|
2291
|
+
if (c.Ports) {
|
|
2292
|
+
const portMatches = c.Ports.match(/(\d+)->(\d+)/g)
|
|
2293
|
+
if (portMatches) {
|
|
2294
|
+
portMatches.forEach((p: string) => {
|
|
2295
|
+
const [host, container] = p.split('->').map(Number)
|
|
2296
|
+
ports.push({ host, container })
|
|
2297
|
+
})
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
return {
|
|
2301
|
+
containerId: c.ID,
|
|
2302
|
+
name: c.Names.replace(/^\//, ''), // Remove leading slash
|
|
2303
|
+
image: c.Image,
|
|
2304
|
+
status: c.State === 'running' ? 'running' : c.State === 'paused' ? 'paused' : c.Status === 'exited' ? 'exited' : 'stopped',
|
|
2305
|
+
ports,
|
|
2306
|
+
createdAt: new Date(c.CreatedAt).toISOString()
|
|
2307
|
+
}
|
|
2308
|
+
} catch {
|
|
2309
|
+
return null
|
|
2310
|
+
}
|
|
2311
|
+
}).filter(Boolean)
|
|
2312
|
+
|
|
2313
|
+
socket!.emit('container:list:response', { success: true, containers })
|
|
2314
|
+
} catch (error: any) {
|
|
2315
|
+
socket!.emit('container:list:response', { success: false, error: error.message })
|
|
2316
|
+
}
|
|
2317
|
+
})
|
|
2318
|
+
|
|
2319
|
+
// Start a new container
|
|
2320
|
+
socket.on('container:start:request', async (data: {
|
|
2321
|
+
name: string
|
|
2322
|
+
image: string
|
|
2323
|
+
ports?: Array<{ host: number; container: number }>
|
|
2324
|
+
env?: Record<string, string>
|
|
2325
|
+
runAsRoot?: boolean // Allow running as root inside container (safe, still isolated)
|
|
2326
|
+
}) => {
|
|
2327
|
+
try {
|
|
2328
|
+
const runtime = getContainerRuntime()
|
|
2329
|
+
if (!runtime) {
|
|
2330
|
+
socket!.emit('container:start:response', { success: false, error: 'No container runtime found' })
|
|
2331
|
+
return
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
const { name, image, ports = [], env = {}, runAsRoot = false } = data
|
|
2335
|
+
|
|
2336
|
+
// Get CLI directory path (where this script is running from)
|
|
2337
|
+
// Use the same pattern as elsewhere in the file
|
|
2338
|
+
const cliDir = path.dirname(CURRENT_FILE)
|
|
2339
|
+
|
|
2340
|
+
// Build docker run command
|
|
2341
|
+
let cmd = `${runtime} run -d --name ${name}`
|
|
2342
|
+
|
|
2343
|
+
// Security: Running as root INSIDE container is safe - it's still isolated from host
|
|
2344
|
+
// Root inside container cannot access host filesystem (read-only mounts)
|
|
2345
|
+
// Default to root to allow package installation and full container functionality
|
|
2346
|
+
// Set runAsRoot: false to run as non-root user (UID 1000) if needed
|
|
2347
|
+
if (!runAsRoot) {
|
|
2348
|
+
cmd += ' --user 1000:1000'
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// Resource limits
|
|
2352
|
+
cmd += ' --memory=8g' // Limit memory to 8GB
|
|
2353
|
+
|
|
2354
|
+
// Auto-remove on exit (optional - commented out for persistence)
|
|
2355
|
+
// cmd += ' --rm'
|
|
2356
|
+
|
|
2357
|
+
// Mount CLI directory into container
|
|
2358
|
+
cmd += ` -v "${cliDir}:/opt/ttc:ro"`
|
|
2359
|
+
|
|
2360
|
+
// Mount entrypoint script
|
|
2361
|
+
const entrypointScript = path.join(cliDir, 'container-entrypoint.sh')
|
|
2362
|
+
cmd += ` -v "${entrypointScript}:/entrypoint.sh:ro"`
|
|
2363
|
+
|
|
2364
|
+
// Port mappings
|
|
2365
|
+
ports.forEach(p => {
|
|
2366
|
+
cmd += ` -p ${p.host}:${p.container}`
|
|
2367
|
+
})
|
|
2368
|
+
|
|
2369
|
+
// Environment variables
|
|
2370
|
+
Object.entries(env).forEach(([k, v]) => {
|
|
2371
|
+
// Escape quotes in env values
|
|
2372
|
+
const escapedValue = String(v).replace(/"/g, '\\"')
|
|
2373
|
+
cmd += ` -e ${k}="${escapedValue}"`
|
|
2374
|
+
})
|
|
2375
|
+
|
|
2376
|
+
// Add container-specific environment variables
|
|
2377
|
+
cmd += ` -e CONTAINER_NAME="${name}"`
|
|
2378
|
+
cmd += ` -e HOSTNAME="${name}"`
|
|
2379
|
+
|
|
2380
|
+
// Use entrypoint script
|
|
2381
|
+
cmd += ` --entrypoint /bin/sh ${image} /entrypoint.sh`
|
|
2382
|
+
|
|
2383
|
+
if (foreground) {
|
|
2384
|
+
console.log(`Starting container: ${cmd}`)
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Pull image if not exists, then run
|
|
2388
|
+
try {
|
|
2389
|
+
execSync(`${runtime} pull ${image}`, { stdio: 'ignore' })
|
|
2390
|
+
} catch {
|
|
2391
|
+
// Pull failed, but might already exist locally
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const output = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' })
|
|
2395
|
+
const containerId = output.trim()
|
|
2396
|
+
|
|
2397
|
+
socket!.emit('container:start:response', { success: true, containerId })
|
|
2398
|
+
|
|
2399
|
+
if (foreground) {
|
|
2400
|
+
console.log(`✓ Container started: ${containerId}`)
|
|
2401
|
+
}
|
|
2402
|
+
} catch (error: any) {
|
|
2403
|
+
socket!.emit('container:start:response', { success: false, error: error.message })
|
|
2404
|
+
}
|
|
2405
|
+
})
|
|
2406
|
+
|
|
2407
|
+
// Stop a container
|
|
2408
|
+
socket.on('container:stop:request', async (data: { containerId: string }) => {
|
|
2409
|
+
try {
|
|
2410
|
+
const runtime = getContainerRuntime()
|
|
2411
|
+
if (!runtime) {
|
|
2412
|
+
socket!.emit('container:stop:response', { success: false, error: 'No container runtime found' })
|
|
2413
|
+
return
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const { containerId } = data
|
|
2417
|
+
|
|
2418
|
+
execSync(`${runtime} stop ${containerId}`, { stdio: 'ignore' })
|
|
2419
|
+
|
|
2420
|
+
socket!.emit('container:stop:response', { success: true })
|
|
2421
|
+
|
|
2422
|
+
if (foreground) {
|
|
2423
|
+
console.log(`✓ Container stopped: ${containerId}`)
|
|
2424
|
+
}
|
|
2425
|
+
} catch (error: any) {
|
|
2426
|
+
socket!.emit('container:stop:response', { success: false, error: error.message })
|
|
2427
|
+
}
|
|
2428
|
+
})
|
|
2429
|
+
|
|
2430
|
+
// Remove a container
|
|
2431
|
+
socket.on('container:remove:request', async (data: { containerId: string }) => {
|
|
2432
|
+
try {
|
|
2433
|
+
const runtime = getContainerRuntime()
|
|
2434
|
+
if (!runtime) {
|
|
2435
|
+
socket!.emit('container:remove:response', { success: false, error: 'No container runtime found' })
|
|
2436
|
+
return
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
const { containerId } = data
|
|
2440
|
+
|
|
2441
|
+
// Force remove even if running
|
|
2442
|
+
execSync(`${runtime} rm -f ${containerId}`, { stdio: 'ignore' })
|
|
2443
|
+
|
|
2444
|
+
socket!.emit('container:remove:response', { success: true })
|
|
2445
|
+
|
|
2446
|
+
if (foreground) {
|
|
2447
|
+
console.log(`✓ Container removed: ${containerId}`)
|
|
2448
|
+
}
|
|
2449
|
+
} catch (error: any) {
|
|
2450
|
+
socket!.emit('container:remove:response', { success: false, error: error.message })
|
|
2451
|
+
}
|
|
2452
|
+
})
|
|
2453
|
+
|
|
2454
|
+
// Get container logs
|
|
2455
|
+
socket.on('container:logs:request', async (data: { containerId: string; lines?: number }) => {
|
|
2456
|
+
try {
|
|
2457
|
+
const runtime = getContainerRuntime()
|
|
2458
|
+
if (!runtime) {
|
|
2459
|
+
socket!.emit('container:logs:response', { success: false, error: 'No container runtime found' })
|
|
2460
|
+
return
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const { containerId, lines = 100 } = data
|
|
2464
|
+
|
|
2465
|
+
const logs = execSync(
|
|
2466
|
+
`${runtime} logs --tail ${lines} ${containerId}`,
|
|
2467
|
+
{ encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
|
|
2468
|
+
)
|
|
2469
|
+
|
|
2470
|
+
socket!.emit('container:logs:response', { success: true, logs })
|
|
2471
|
+
} catch (error: any) {
|
|
2472
|
+
socket!.emit('container:logs:response', { success: false, error: error.message })
|
|
2473
|
+
}
|
|
2474
|
+
})
|
|
2475
|
+
|
|
2476
|
+
// Helper function to get available container runtime
|
|
2477
|
+
function getContainerRuntime(): 'docker' | 'podman' | null {
|
|
2478
|
+
try {
|
|
2479
|
+
execSync('which docker', { stdio: 'ignore' })
|
|
2480
|
+
return 'docker'
|
|
2481
|
+
} catch {
|
|
2482
|
+
try {
|
|
2483
|
+
execSync('which podman', { stdio: 'ignore' })
|
|
2484
|
+
return 'podman'
|
|
2485
|
+
} catch {
|
|
2486
|
+
return null
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
socket.on('disconnect', (reason) => {
|
|
2492
|
+
if (foreground) {
|
|
2493
|
+
console.log(`\n⚠️ Disconnected: ${reason}`)
|
|
2494
|
+
console.log(' Attempting to reconnect...')
|
|
2495
|
+
} else {
|
|
2496
|
+
console.log(`Disconnected: ${reason}`)
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// Clear heartbeat interval
|
|
2500
|
+
if ((socket as any).heartbeatInterval) {
|
|
2501
|
+
clearInterval((socket as any).heartbeatInterval)
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
if (reason === 'io server disconnect') {
|
|
2505
|
+
// Server disconnected, reconnect manually
|
|
2506
|
+
socket!.connect()
|
|
2507
|
+
} else {
|
|
2508
|
+
// Client disconnect or transport close: schedule full reconnect so process stays alive
|
|
2509
|
+
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
|
2510
|
+
const delay = 2000
|
|
2511
|
+
if (!foreground) console.log(`Reconnecting in ${delay}ms...`)
|
|
2512
|
+
reconnectTimeout = setTimeout(connectAndRegister, delay)
|
|
2513
|
+
}
|
|
2514
|
+
})
|
|
2515
|
+
|
|
2516
|
+
socket.on('connect_error', (err: Error & { type?: string; description?: string; cause?: any }) => {
|
|
2517
|
+
reconnectAttempts++
|
|
2518
|
+
if (foreground) {
|
|
2519
|
+
console.error(`✗ Connection error (attempt ${reconnectAttempts}): ${err.message}`)
|
|
2520
|
+
if (err.type) {
|
|
2521
|
+
console.error(` Error type: ${err.type}`)
|
|
2522
|
+
}
|
|
2523
|
+
if (err.description) {
|
|
2524
|
+
console.error(` Error description: ${err.description}`)
|
|
2525
|
+
}
|
|
2526
|
+
console.error(` Connecting to: ${config.apiUrl}`)
|
|
2527
|
+
if (err.cause) {
|
|
2528
|
+
console.error(` Cause:`, err.cause)
|
|
2529
|
+
}
|
|
2530
|
+
} else {
|
|
2531
|
+
console.error(`Connection error (attempt ${reconnectAttempts}):`, err.message)
|
|
2532
|
+
}
|
|
2533
|
+
})
|
|
2534
|
+
|
|
2535
|
+
// Keep process alive
|
|
2536
|
+
socket.on('error', (err) => {
|
|
2537
|
+
if (foreground) {
|
|
2538
|
+
console.error(`✗ Socket error: ${err}`)
|
|
2539
|
+
} else {
|
|
2540
|
+
console.error('Socket error:', err)
|
|
2541
|
+
}
|
|
2542
|
+
})
|
|
2543
|
+
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
if (foreground) {
|
|
2546
|
+
console.error(`✗ Failed to connect: ${(error as Error).message}`)
|
|
2547
|
+
} else {
|
|
2548
|
+
console.error('Failed to connect:', (error as Error).message)
|
|
2549
|
+
}
|
|
2550
|
+
reconnectAttempts++
|
|
2551
|
+
|
|
2552
|
+
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
2553
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
|
|
2554
|
+
if (foreground) {
|
|
2555
|
+
console.log(`⏳ Reconnecting in ${delay}ms...`)
|
|
2556
|
+
} else {
|
|
2557
|
+
console.log(`Reconnecting in ${delay}ms...`)
|
|
2558
|
+
}
|
|
2559
|
+
reconnectTimeout = setTimeout(connectAndRegister, delay)
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// Handle graceful shutdown
|
|
2565
|
+
const shutdown = () => {
|
|
2566
|
+
if (foreground) {
|
|
2567
|
+
console.log('\n\n🛑 Shutting down gracefully...')
|
|
2568
|
+
} else {
|
|
2569
|
+
console.log('\nShutting down...')
|
|
2570
|
+
}
|
|
2571
|
+
if (reconnectTimeout) {
|
|
2572
|
+
clearTimeout(reconnectTimeout)
|
|
2573
|
+
}
|
|
2574
|
+
if (socket) {
|
|
2575
|
+
// Clear heartbeat interval
|
|
2576
|
+
if ((socket as any).heartbeatInterval) {
|
|
2577
|
+
clearInterval((socket as any).heartbeatInterval)
|
|
2578
|
+
}
|
|
2579
|
+
socket.disconnect()
|
|
2580
|
+
}
|
|
2581
|
+
process.exit(0)
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
process.on('SIGTERM', shutdown)
|
|
2585
|
+
process.on('SIGINT', shutdown)
|
|
2586
|
+
process.on('SIGHUP', shutdown)
|
|
2587
|
+
|
|
2588
|
+
// Start connection
|
|
2589
|
+
await connectAndRegister()
|
|
2590
|
+
|
|
2591
|
+
// Keep process alive
|
|
2592
|
+
process.stdin.resume()
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// ============ CLI Program ============
|
|
2596
|
+
|
|
2597
|
+
const program = new Command()
|
|
2598
|
+
|
|
2599
|
+
program
|
|
2600
|
+
.name('ttc')
|
|
2601
|
+
.description('TalkToCode CLI - Simple install and manage')
|
|
2602
|
+
.version('1.0.0')
|
|
2603
|
+
|
|
2604
|
+
// Config command
|
|
2605
|
+
program
|
|
2606
|
+
.command('config')
|
|
2607
|
+
.description('Get or set config (api-url)')
|
|
2608
|
+
.option('--api-url <url>', 'API base URL')
|
|
2609
|
+
.action(async (opts: { apiUrl?: string }) => {
|
|
2610
|
+
const config = await readConfig()
|
|
2611
|
+
if (opts.apiUrl !== undefined) {
|
|
2612
|
+
config.apiUrl = opts.apiUrl
|
|
2613
|
+
await writeConfig(config)
|
|
2614
|
+
console.log('Set api-url:', config.apiUrl)
|
|
2615
|
+
} else {
|
|
2616
|
+
console.log('Config:', JSON.stringify(config, null, 2))
|
|
2617
|
+
}
|
|
2618
|
+
process.exit(0)
|
|
2619
|
+
})
|
|
2620
|
+
|
|
2621
|
+
// Install command
|
|
2622
|
+
program
|
|
2623
|
+
.command('install')
|
|
2624
|
+
.description('Install and register TTC CLI (installs as pm2 daemon)')
|
|
2625
|
+
.argument('[email]', 'Email address for device approval')
|
|
2626
|
+
.action(async (email: string | undefined) => {
|
|
2627
|
+
try {
|
|
2628
|
+
// First register the device
|
|
2629
|
+
await registerDevice(undefined, email)
|
|
2630
|
+
|
|
2631
|
+
// After successful registration, install as service (Linux only)
|
|
2632
|
+
if (process.platform === 'win32') {
|
|
2633
|
+
console.log('\n✓ Device registered.')
|
|
2634
|
+
console.log('On Windows, run the daemon manually: ttc daemon')
|
|
2635
|
+
console.log('To run in background: start /B node path\\to\\ttc daemon')
|
|
2636
|
+
} else {
|
|
2637
|
+
console.log('\n📦 Installing TTC with pm2...')
|
|
2638
|
+
// install-service.sh: bundled at __dirname, or in ~/.talk-to-code for curl install
|
|
2639
|
+
let installScript = path.join(__dirname, 'install-service.sh')
|
|
2640
|
+
if (!fsSync.existsSync(installScript)) {
|
|
2641
|
+
installScript = path.join(CONFIG_DIR, 'install-service.sh')
|
|
2642
|
+
}
|
|
2643
|
+
if (!fsSync.existsSync(installScript)) {
|
|
2644
|
+
installScript = path.join(__dirname, '..', 'install-service.sh')
|
|
2645
|
+
}
|
|
2646
|
+
const scriptDir = path.dirname(installScript)
|
|
2647
|
+
try {
|
|
2648
|
+
await fs.access(installScript)
|
|
2649
|
+
const { execSync } = await import('child_process')
|
|
2650
|
+
execSync(`bash "${installScript}"`, {
|
|
2651
|
+
stdio: 'inherit',
|
|
2652
|
+
cwd: scriptDir,
|
|
2653
|
+
env: { ...process.env, PATH: process.env.PATH }
|
|
2654
|
+
})
|
|
2655
|
+
console.log('\n✓ TTC installation complete!')
|
|
2656
|
+
console.log('The service is now running in the background.')
|
|
2657
|
+
} catch (scriptErr: unknown) {
|
|
2658
|
+
const err = scriptErr as Error
|
|
2659
|
+
console.log('\n⚠ Service installation had issues, but device is registered.')
|
|
2660
|
+
if (err.message) console.error('Error:', err.message)
|
|
2661
|
+
console.log('You can start the daemon manually with: ttc daemon')
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
const errorMsg = (error as Error).message
|
|
2666
|
+
if (errorMsg.includes('approval') || errorMsg.includes('email')) {
|
|
2667
|
+
// Registration failed - exit with error
|
|
2668
|
+
process.exit(1)
|
|
2669
|
+
}
|
|
2670
|
+
// Service installation failed, but device is registered
|
|
2671
|
+
console.log('\n⚠ Service installation had issues, but device is registered.')
|
|
2672
|
+
console.log('You can start the daemon manually with: ttc daemon')
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// Exit after install is complete
|
|
2676
|
+
process.exit(0)
|
|
2677
|
+
})
|
|
2678
|
+
|
|
2679
|
+
// Uninstall command
|
|
2680
|
+
program
|
|
2681
|
+
.command('uninstall')
|
|
2682
|
+
.description('Uninstall TTC CLI (removes service and config)')
|
|
2683
|
+
.action(async () => {
|
|
2684
|
+
console.log('🗑️ Uninstalling TTC CLI...')
|
|
2685
|
+
|
|
2686
|
+
if (process.platform === 'win32') {
|
|
2687
|
+
console.log('On Windows there is no service to remove.')
|
|
2688
|
+
console.log('To remove TTC completely:')
|
|
2689
|
+
console.log(' 1. Delete the ttc executable or folder')
|
|
2690
|
+
console.log(' 2. Remove config: rmdir /s /q "%USERPROFILE%\\.talk-to-code"')
|
|
2691
|
+
} else {
|
|
2692
|
+
let installScript = path.join(__dirname, 'install-service.sh')
|
|
2693
|
+
if (!fsSync.existsSync(installScript)) installScript = path.join(CONFIG_DIR, 'install-service.sh')
|
|
2694
|
+
if (!fsSync.existsSync(installScript)) installScript = path.join(__dirname, '..', 'install-service.sh')
|
|
2695
|
+
const scriptDir = path.dirname(installScript)
|
|
2696
|
+
try {
|
|
2697
|
+
await fs.access(installScript)
|
|
2698
|
+
const { execSync } = await import('child_process')
|
|
2699
|
+
execSync(`bash "${installScript}" --uninstall`, {
|
|
2700
|
+
stdio: 'inherit',
|
|
2701
|
+
cwd: scriptDir,
|
|
2702
|
+
env: { ...process.env, PATH: process.env.PATH }
|
|
2703
|
+
})
|
|
2704
|
+
console.log('\n✓ Service uninstalled!')
|
|
2705
|
+
console.log('To remove TTC completely, delete the binary:')
|
|
2706
|
+
console.log(' rm ~/.local/bin/ttc')
|
|
2707
|
+
} catch (error) {
|
|
2708
|
+
console.log('\n⚠ Service uninstall script not found or failed.')
|
|
2709
|
+
console.log('To remove TTC completely, delete:')
|
|
2710
|
+
console.log(' rm ~/.local/bin/ttc')
|
|
2711
|
+
console.log(' rm -rf ~/.talk-to-code')
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// Exit after uninstall is complete
|
|
2716
|
+
process.exit(0)
|
|
2717
|
+
})
|
|
2718
|
+
|
|
2719
|
+
// Update command
|
|
2720
|
+
program
|
|
2721
|
+
.command('update')
|
|
2722
|
+
.description('Check for and apply CLI updates')
|
|
2723
|
+
.option('-f, --force', 'Update without confirmation')
|
|
2724
|
+
.action(async (options: { force?: boolean }) => {
|
|
2725
|
+
await selfUpdate(options.force)
|
|
2726
|
+
process.exit(0)
|
|
2727
|
+
})
|
|
2728
|
+
|
|
2729
|
+
program
|
|
2730
|
+
.command('check-update')
|
|
2731
|
+
.description('Check for updates')
|
|
2732
|
+
.action(async () => {
|
|
2733
|
+
const info = await checkForUpdate()
|
|
2734
|
+
if (!info) process.exit(1)
|
|
2735
|
+
if (!info.updateAvailable) {
|
|
2736
|
+
console.log('✓ Up to date')
|
|
2737
|
+
process.exit(0)
|
|
2738
|
+
}
|
|
2739
|
+
console.log('📦 Update available')
|
|
2740
|
+
console.log('Run "ttc update" to install')
|
|
2741
|
+
process.exit(0)
|
|
2742
|
+
})
|
|
2743
|
+
|
|
2744
|
+
// Run (connect to backend and process prompts)
|
|
2745
|
+
program
|
|
2746
|
+
.command('run')
|
|
2747
|
+
.description('Run CLI and connect to backend')
|
|
2748
|
+
.option('--email <email>', 'Email for device approval')
|
|
2749
|
+
.action((options: { email?: string }) => {
|
|
2750
|
+
runDaemon(false, options.email).catch((error) => {
|
|
2751
|
+
console.error('Run error:', (error as Error).message)
|
|
2752
|
+
process.exit(1)
|
|
2753
|
+
})
|
|
2754
|
+
})
|
|
2755
|
+
|
|
2756
|
+
// Hidden daemon command (internal alias)
|
|
2757
|
+
program
|
|
2758
|
+
.command('daemon', { hidden: true })
|
|
2759
|
+
.description('Run daemon (internal)')
|
|
2760
|
+
.option('--foreground', 'Run in foreground', false)
|
|
2761
|
+
.option('--email <email>', 'Email address')
|
|
2762
|
+
.action((options: { foreground?: boolean; email?: string }) => {
|
|
2763
|
+
runDaemon(options.foreground, options.email).catch((error) => {
|
|
2764
|
+
console.error('Daemon error:', (error as Error).message)
|
|
2765
|
+
process.exit(1)
|
|
2766
|
+
})
|
|
2767
|
+
})
|
|
2768
|
+
|
|
2769
|
+
program.parse()
|