@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/appRunner.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import os from 'os'
|
|
5
|
+
import type { ProjectApp } from './shared/types'
|
|
6
|
+
|
|
7
|
+
/** Cross-platform: run a shell command (sh -c on Unix, cmd /c on Windows) */
|
|
8
|
+
function shellSpawnOpts(command: string): { shell: string; args: string[] } {
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
return { shell: process.env.COMSPEC || 'cmd.exe', args: ['/c', command] }
|
|
11
|
+
}
|
|
12
|
+
return { shell: 'sh', args: ['-c', command] }
|
|
13
|
+
}
|
|
14
|
+
import Fastify, { FastifyInstance } from 'fastify'
|
|
15
|
+
import fastifyStatic from '@fastify/static'
|
|
16
|
+
|
|
17
|
+
// ============ Types ============
|
|
18
|
+
|
|
19
|
+
export interface RunnerStats {
|
|
20
|
+
startTime: number
|
|
21
|
+
requests: number
|
|
22
|
+
errors: number
|
|
23
|
+
lastRequestTime?: number
|
|
24
|
+
memoryUsage?: NodeJS.MemoryUsage
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RunnerOutput {
|
|
28
|
+
type: 'stdout' | 'stderr' | 'system'
|
|
29
|
+
data: string
|
|
30
|
+
timestamp: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RunnerCallbacks {
|
|
34
|
+
onOutput?: (output: RunnerOutput) => void
|
|
35
|
+
onError?: (error: string) => void
|
|
36
|
+
onExit?: (code: number | null) => void
|
|
37
|
+
onStats?: (stats: RunnerStats) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============ Base Runner ============
|
|
41
|
+
|
|
42
|
+
export abstract class BaseRunner {
|
|
43
|
+
protected app: ProjectApp
|
|
44
|
+
protected projectPath: string
|
|
45
|
+
protected projectId: string
|
|
46
|
+
protected callbacks: RunnerCallbacks
|
|
47
|
+
protected stats: RunnerStats
|
|
48
|
+
protected process: ChildProcess | null = null
|
|
49
|
+
protected logFile: string
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
app: ProjectApp,
|
|
53
|
+
projectPath: string,
|
|
54
|
+
projectId: string,
|
|
55
|
+
callbacks: RunnerCallbacks
|
|
56
|
+
) {
|
|
57
|
+
this.app = app
|
|
58
|
+
this.projectPath = projectPath
|
|
59
|
+
this.projectId = projectId
|
|
60
|
+
this.callbacks = callbacks
|
|
61
|
+
this.stats = {
|
|
62
|
+
startTime: Date.now(),
|
|
63
|
+
requests: 0,
|
|
64
|
+
errors: 0,
|
|
65
|
+
}
|
|
66
|
+
this.logFile = path.join(os.homedir(), '.talk-to-code', 'app-logs', `${projectId}-${app.name}.log`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
abstract start(): Promise<{ success: boolean; pid?: number; error?: string }>
|
|
70
|
+
abstract stop(): Promise<{ success: boolean; error?: string }>
|
|
71
|
+
abstract getStats(): RunnerStats
|
|
72
|
+
|
|
73
|
+
protected async writeLog(type: 'stdout' | 'stderr' | 'system', data: string): Promise<void> {
|
|
74
|
+
const timestamp = new Date().toISOString()
|
|
75
|
+
const logLine = `[${timestamp}] [${type.toUpperCase()}] ${data}\n`
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await fs.appendFile(this.logFile, logLine, 'utf-8')
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Ignore log write errors
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.callbacks.onOutput?.({
|
|
84
|
+
type,
|
|
85
|
+
data,
|
|
86
|
+
timestamp: Date.now(),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected updateStats(): void {
|
|
91
|
+
if (this.process) {
|
|
92
|
+
try {
|
|
93
|
+
const memUsage = process.memoryUsage()
|
|
94
|
+
this.stats.memoryUsage = memUsage
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.callbacks.onStats?.(this.stats)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============ Static Frontend Runner ============
|
|
104
|
+
|
|
105
|
+
export class StaticFrontendRunner extends BaseRunner {
|
|
106
|
+
private fastify: FastifyInstance | null = null
|
|
107
|
+
private port: number
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
app: ProjectApp,
|
|
111
|
+
projectPath: string,
|
|
112
|
+
projectId: string,
|
|
113
|
+
callbacks: RunnerCallbacks
|
|
114
|
+
) {
|
|
115
|
+
super(app, projectPath, projectId, callbacks)
|
|
116
|
+
this.port = app.port || 3000
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async start(): Promise<{ success: boolean; pid?: number; error?: string }> {
|
|
120
|
+
try {
|
|
121
|
+
await this.writeLog('system', `Starting static frontend server for ${this.app.name} on port ${this.port}`)
|
|
122
|
+
|
|
123
|
+
// Determine build directory (use buildDir from config if provided)
|
|
124
|
+
const buildDirName = this.app.buildDir || 'dist'
|
|
125
|
+
const buildDir = this.app.directory
|
|
126
|
+
? path.join(this.projectPath, this.app.directory, buildDirName)
|
|
127
|
+
: path.join(this.projectPath, buildDirName)
|
|
128
|
+
|
|
129
|
+
// Check if specified build dir exists, fallback to dist, build, or public
|
|
130
|
+
let staticDir = buildDir
|
|
131
|
+
const dirsToTry = buildDirName !== 'dist' ? [buildDirName, 'dist', 'build', 'public'] : ['dist', 'build', 'public']
|
|
132
|
+
|
|
133
|
+
let found = false
|
|
134
|
+
for (const dirName of dirsToTry) {
|
|
135
|
+
const testDir = this.app.directory
|
|
136
|
+
? path.join(this.projectPath, this.app.directory, dirName)
|
|
137
|
+
: path.join(this.projectPath, dirName)
|
|
138
|
+
try {
|
|
139
|
+
await fs.access(testDir)
|
|
140
|
+
staticDir = testDir
|
|
141
|
+
found = true
|
|
142
|
+
break
|
|
143
|
+
} catch {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!found) {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
error: `No build directory found. Tried: ${dirsToTry.join(', ')}`
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await this.writeLog('system', `Serving static files from: ${staticDir}`)
|
|
156
|
+
|
|
157
|
+
// Create Fastify instance
|
|
158
|
+
this.fastify = Fastify({
|
|
159
|
+
logger: {
|
|
160
|
+
level: 'info',
|
|
161
|
+
transport: {
|
|
162
|
+
target: 'pino-pretty',
|
|
163
|
+
options: {
|
|
164
|
+
translateTime: 'HH:MM:ss Z',
|
|
165
|
+
ignore: 'pid,hostname',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Register static file serving
|
|
172
|
+
await this.fastify.register(fastifyStatic, {
|
|
173
|
+
root: staticDir,
|
|
174
|
+
prefix: '/',
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Add request logging middleware
|
|
178
|
+
this.fastify.addHook('onRequest', async (request, reply) => {
|
|
179
|
+
this.stats.requests++
|
|
180
|
+
this.stats.lastRequestTime = Date.now()
|
|
181
|
+
this.updateStats()
|
|
182
|
+
|
|
183
|
+
await this.writeLog('stdout', `${request.method} ${request.url} - ${reply.statusCode}`)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Add error handling
|
|
187
|
+
this.fastify.setErrorHandler(async (error: unknown, request, reply) => {
|
|
188
|
+
this.stats.errors++
|
|
189
|
+
this.updateStats()
|
|
190
|
+
|
|
191
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
192
|
+
await this.writeLog('stderr', `Error: ${errorMessage} - ${request.method} ${request.url}`)
|
|
193
|
+
|
|
194
|
+
reply.status(500).send({ error: 'Internal Server Error' })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Start server
|
|
198
|
+
await this.fastify.listen({ port: this.port, host: '0.0.0.0' })
|
|
199
|
+
|
|
200
|
+
await this.writeLog('system', `Static server started successfully on port ${this.port}`)
|
|
201
|
+
|
|
202
|
+
// Create a mock process for compatibility
|
|
203
|
+
this.process = {
|
|
204
|
+
pid: process.pid,
|
|
205
|
+
kill: (signal?: NodeJS.Signals) => {
|
|
206
|
+
this.stop()
|
|
207
|
+
},
|
|
208
|
+
} as ChildProcess
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
success: true,
|
|
212
|
+
pid: process.pid,
|
|
213
|
+
}
|
|
214
|
+
} catch (error: any) {
|
|
215
|
+
await this.writeLog('stderr', `Failed to start static server: ${error.message}`)
|
|
216
|
+
this.callbacks.onError?.(error.message)
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
error: error.message || 'Failed to start static server'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async stop(): Promise<{ success: boolean; error?: string }> {
|
|
225
|
+
try {
|
|
226
|
+
if (this.fastify) {
|
|
227
|
+
await this.writeLog('system', `Stopping static server for ${this.app.name}`)
|
|
228
|
+
await this.fastify.close()
|
|
229
|
+
this.fastify = null
|
|
230
|
+
await this.writeLog('system', `Static server stopped`)
|
|
231
|
+
this.callbacks.onExit?.(0)
|
|
232
|
+
return { success: true }
|
|
233
|
+
}
|
|
234
|
+
return { success: true }
|
|
235
|
+
} catch (error: any) {
|
|
236
|
+
await this.writeLog('stderr', `Error stopping static server: ${error.message}`)
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
error: error.message || 'Failed to stop static server'
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
getStats(): RunnerStats {
|
|
245
|
+
return {
|
|
246
|
+
...this.stats,
|
|
247
|
+
memoryUsage: process.memoryUsage(),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ============ Backend Runner (Express/Fastify wrapper) ============
|
|
253
|
+
|
|
254
|
+
export class BackendRunner extends BaseRunner {
|
|
255
|
+
// process is inherited from BaseRunner as protected
|
|
256
|
+
|
|
257
|
+
async start(): Promise<{ success: boolean; pid?: number; error?: string }> {
|
|
258
|
+
try {
|
|
259
|
+
await this.writeLog('system', `Starting backend app: ${this.app.name}`)
|
|
260
|
+
|
|
261
|
+
// Determine working directory
|
|
262
|
+
const workingDir = this.app.directory
|
|
263
|
+
? path.join(this.projectPath, this.app.directory)
|
|
264
|
+
: this.projectPath
|
|
265
|
+
|
|
266
|
+
// Check if directory exists
|
|
267
|
+
try {
|
|
268
|
+
await fs.access(workingDir)
|
|
269
|
+
} catch {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
error: `App directory does not exist: ${this.app.directory || 'root'}`
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Prepare environment variables
|
|
277
|
+
const env = {
|
|
278
|
+
...process.env,
|
|
279
|
+
...this.app.env,
|
|
280
|
+
NODE_ENV: this.app.env?.NODE_ENV || process.env.NODE_ENV || 'development',
|
|
281
|
+
PORT: this.app.port?.toString() || process.env.PORT || '3000',
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Ensure log directory exists
|
|
285
|
+
const logDir = path.dirname(this.logFile)
|
|
286
|
+
await fs.mkdir(logDir, { recursive: true })
|
|
287
|
+
|
|
288
|
+
// Spawn the backend process (cross-platform shell)
|
|
289
|
+
const { shell, args } = shellSpawnOpts(this.app.startCommand)
|
|
290
|
+
this.process = spawn(shell, args, {
|
|
291
|
+
cwd: workingDir,
|
|
292
|
+
env,
|
|
293
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
294
|
+
detached: false,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
if (!this.process.pid) {
|
|
298
|
+
return {
|
|
299
|
+
success: false,
|
|
300
|
+
error: 'Failed to spawn process'
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await this.writeLog('system', `Backend process started with PID: ${this.process.pid}`)
|
|
305
|
+
|
|
306
|
+
// Capture stdout
|
|
307
|
+
this.process.stdout?.on('data', async (data: Buffer) => {
|
|
308
|
+
const output = data.toString()
|
|
309
|
+
await this.writeLog('stdout', output)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// Capture stderr
|
|
313
|
+
this.process.stderr?.on('data', async (data: Buffer) => {
|
|
314
|
+
const output = data.toString()
|
|
315
|
+
this.stats.errors++
|
|
316
|
+
this.updateStats()
|
|
317
|
+
await this.writeLog('stderr', output)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Handle process exit
|
|
321
|
+
this.process.on('exit', async (code) => {
|
|
322
|
+
await this.writeLog('system', `Process exited with code ${code}`)
|
|
323
|
+
this.process = null
|
|
324
|
+
this.callbacks.onExit?.(code)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// Handle process errors
|
|
328
|
+
this.process.on('error', async (error) => {
|
|
329
|
+
this.stats.errors++
|
|
330
|
+
this.updateStats()
|
|
331
|
+
await this.writeLog('stderr', `Process error: ${error.message}`)
|
|
332
|
+
this.callbacks.onError?.(error.message)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// Start stats collection interval
|
|
336
|
+
const statsInterval = setInterval(() => {
|
|
337
|
+
if (this.process) {
|
|
338
|
+
this.updateStats()
|
|
339
|
+
} else {
|
|
340
|
+
clearInterval(statsInterval)
|
|
341
|
+
}
|
|
342
|
+
}, 5000)
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
success: true,
|
|
346
|
+
pid: this.process.pid,
|
|
347
|
+
}
|
|
348
|
+
} catch (error: any) {
|
|
349
|
+
await this.writeLog('stderr', `Failed to start backend: ${error.message}`)
|
|
350
|
+
this.callbacks.onError?.(error.message)
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
error: error.message || 'Failed to start backend'
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async stop(): Promise<{ success: boolean; error?: string }> {
|
|
359
|
+
try {
|
|
360
|
+
if (!this.process) {
|
|
361
|
+
return { success: true }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await this.writeLog('system', `Stopping backend app: ${this.app.name}`)
|
|
365
|
+
|
|
366
|
+
// Try custom stop command first
|
|
367
|
+
if (this.app.stopCommand) {
|
|
368
|
+
try {
|
|
369
|
+
const workingDir = this.app.directory
|
|
370
|
+
? path.join(this.projectPath, this.app.directory)
|
|
371
|
+
: this.projectPath
|
|
372
|
+
|
|
373
|
+
const { shell: stopShell, args: stopArgs } = shellSpawnOpts(this.app.stopCommand)
|
|
374
|
+
const stopProcess = spawn(stopShell, stopArgs, {
|
|
375
|
+
cwd: workingDir,
|
|
376
|
+
stdio: 'ignore',
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// Wait for graceful shutdown
|
|
380
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
381
|
+
|
|
382
|
+
// If still running, kill it
|
|
383
|
+
if (this.process && this.process.pid) {
|
|
384
|
+
try {
|
|
385
|
+
process.kill(this.process.pid, 0) // Check if still alive
|
|
386
|
+
this.process.kill('SIGTERM')
|
|
387
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
388
|
+
if (this.process && this.process.pid) {
|
|
389
|
+
try {
|
|
390
|
+
process.kill(this.process.pid, 0)
|
|
391
|
+
this.process.kill('SIGKILL')
|
|
392
|
+
} catch {
|
|
393
|
+
// Process already dead
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
// Process already dead
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
// Fall back to killing process
|
|
402
|
+
if (this.process) {
|
|
403
|
+
this.process.kill('SIGTERM')
|
|
404
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
405
|
+
if (this.process) {
|
|
406
|
+
this.process.kill('SIGKILL')
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// Default: kill the process
|
|
412
|
+
this.process.kill('SIGTERM')
|
|
413
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
414
|
+
if (this.process) {
|
|
415
|
+
this.process.kill('SIGKILL')
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.process = null
|
|
420
|
+
await this.writeLog('system', `Backend app stopped`)
|
|
421
|
+
return { success: true }
|
|
422
|
+
} catch (error: any) {
|
|
423
|
+
await this.writeLog('stderr', `Error stopping backend: ${error.message}`)
|
|
424
|
+
return {
|
|
425
|
+
success: false,
|
|
426
|
+
error: error.message || 'Failed to stop backend'
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
getStats(): RunnerStats {
|
|
432
|
+
return {
|
|
433
|
+
...this.stats,
|
|
434
|
+
memoryUsage: this.process ? process.memoryUsage() : undefined,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ============ Runner Factory ============
|
|
440
|
+
|
|
441
|
+
export function createRunner(
|
|
442
|
+
app: ProjectApp,
|
|
443
|
+
projectPath: string,
|
|
444
|
+
projectId: string,
|
|
445
|
+
callbacks: RunnerCallbacks
|
|
446
|
+
): BaseRunner {
|
|
447
|
+
// Use explicit appType if provided, otherwise detect
|
|
448
|
+
if (app.appType === 'static-frontend') {
|
|
449
|
+
return new StaticFrontendRunner(app, projectPath, projectId, callbacks)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (app.appType === 'backend') {
|
|
453
|
+
return new BackendRunner(app, projectPath, projectId, callbacks)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Auto-detect based on framework and other indicators
|
|
457
|
+
const framework = app.framework?.toLowerCase() || ''
|
|
458
|
+
const type = app.type?.toLowerCase() || ''
|
|
459
|
+
|
|
460
|
+
// Static frontend apps
|
|
461
|
+
if (
|
|
462
|
+
framework.includes('react') ||
|
|
463
|
+
framework.includes('vue') ||
|
|
464
|
+
framework.includes('angular') ||
|
|
465
|
+
framework.includes('svelte') ||
|
|
466
|
+
(framework.includes('next') && type === 'http') ||
|
|
467
|
+
app.name.toLowerCase().includes('frontend') ||
|
|
468
|
+
app.name.toLowerCase().includes('client')
|
|
469
|
+
) {
|
|
470
|
+
return new StaticFrontendRunner(app, projectPath, projectId, callbacks)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Backend apps (Express, Fastify, etc.)
|
|
474
|
+
return new BackendRunner(app, projectPath, projectId, callbacks)
|
|
475
|
+
}
|
package/bin/exk
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// exk CLI - entry point for npm global install
|
|
3
|
+
// Runs the TypeScript CLI via tsx
|
|
4
|
+
|
|
5
|
+
const { resolve, dirname } = require('path')
|
|
6
|
+
const { spawn } = require('child_process')
|
|
7
|
+
const fs = require('fs')
|
|
8
|
+
|
|
9
|
+
const cliDir = dirname(__filename)
|
|
10
|
+
const pkgDir = resolve(cliDir, '..')
|
|
11
|
+
const entryPoint = resolve(pkgDir, 'index.ts')
|
|
12
|
+
|
|
13
|
+
// Find tsx binary
|
|
14
|
+
let tsxBin = resolve(pkgDir, 'node_modules', '.bin', 'tsx')
|
|
15
|
+
if (!fs.existsSync(tsxBin)) {
|
|
16
|
+
// Walk up to find tsx in parent node_modules (npm global structure)
|
|
17
|
+
let dir = pkgDir
|
|
18
|
+
for (let i = 0; i < 10; i++) {
|
|
19
|
+
const candidate = resolve(dir, 'node_modules', '.bin', 'tsx')
|
|
20
|
+
if (fs.existsSync(candidate)) {
|
|
21
|
+
tsxBin = candidate
|
|
22
|
+
break
|
|
23
|
+
}
|
|
24
|
+
const parent = resolve(dir, '..')
|
|
25
|
+
if (parent === dir) break
|
|
26
|
+
dir = parent
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2)
|
|
31
|
+
|
|
32
|
+
const child = spawn(tsxBin, [entryPoint, ...args], {
|
|
33
|
+
stdio: 'inherit',
|
|
34
|
+
env: { ...process.env },
|
|
35
|
+
cwd: pkgDir
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
child.on('exit', (code) => {
|
|
39
|
+
process.exit(code || 0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
child.on('error', (err) => {
|
|
43
|
+
console.error('Failed to start exk:', err.message)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Don't use set -e, we want to handle errors gracefully
|
|
3
|
+
|
|
4
|
+
# Container entrypoint script for TTC CLI
|
|
5
|
+
# This script runs the TTC CLI daemon inside a container
|
|
6
|
+
|
|
7
|
+
echo "=== TTC Container Entrypoint ==="
|
|
8
|
+
|
|
9
|
+
# Check required environment variables
|
|
10
|
+
if [ -z "$TTC_AUTH_TOKEN" ]; then
|
|
11
|
+
echo "ERROR: TTC_AUTH_TOKEN environment variable is required"
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
if [ -z "$TTC_SERVER_URL" ]; then
|
|
16
|
+
echo "ERROR: TTC_SERVER_URL environment variable is required"
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Check for Node.js
|
|
21
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
22
|
+
echo "ERROR: Node.js is required but not found. Please use a base image with Node.js (e.g., node:20-slim)"
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
echo "Node.js version: $(node --version)"
|
|
27
|
+
|
|
28
|
+
# TTC CLI directory (mounted from host)
|
|
29
|
+
TTC_DIR="/opt/ttc"
|
|
30
|
+
|
|
31
|
+
# Check if CLI is mounted - look for index.js in root or dist directory
|
|
32
|
+
CLI_FILE=""
|
|
33
|
+
if [ -f "$TTC_DIR/index.js" ]; then
|
|
34
|
+
CLI_FILE="$TTC_DIR/index.js"
|
|
35
|
+
elif [ -f "$TTC_DIR/dist/index.js" ]; then
|
|
36
|
+
CLI_FILE="$TTC_DIR/dist/index.js"
|
|
37
|
+
elif [ -f "$TTC_DIR/dist/cli/index.js" ]; then
|
|
38
|
+
CLI_FILE="$TTC_DIR/dist/cli/index.js"
|
|
39
|
+
elif [ -f "$TTC_DIR/index.ts" ]; then
|
|
40
|
+
echo "WARNING: Found TypeScript source file. CLI needs to be built first."
|
|
41
|
+
echo "Please build the CLI before mounting: cd cli && npm run build"
|
|
42
|
+
exit 1
|
|
43
|
+
else
|
|
44
|
+
echo "ERROR: TTC CLI not found at $TTC_DIR"
|
|
45
|
+
echo "The CLI directory must be mounted into the container."
|
|
46
|
+
echo "Expected files: $TTC_DIR/index.js, $TTC_DIR/dist/index.js, or $TTC_DIR/dist/cli/index.js"
|
|
47
|
+
echo "Example: docker run -v /path/to/cli:$TTC_DIR:ro ..."
|
|
48
|
+
echo ""
|
|
49
|
+
echo "Contents of $TTC_DIR:"
|
|
50
|
+
ls -la "$TTC_DIR/" 2>/dev/null || echo "Directory not accessible"
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
echo "TTC CLI found at $CLI_FILE"
|
|
55
|
+
|
|
56
|
+
# Determine config directory based on current user
|
|
57
|
+
# Try common locations for non-root users
|
|
58
|
+
CURRENT_USER=$(id -u)
|
|
59
|
+
CURRENT_HOME=$(eval echo ~$(id -un 2>/dev/null || echo "user"))
|
|
60
|
+
|
|
61
|
+
# Use HOME if available, otherwise try common locations
|
|
62
|
+
# NOTE: CLI uses '.talk-to-code' directory, not '.ttc'
|
|
63
|
+
if [ -n "$HOME" ] && [ -d "$HOME" ]; then
|
|
64
|
+
CONFIG_DIR="$HOME/.talk-to-code"
|
|
65
|
+
elif [ "$CURRENT_USER" = "1000" ] && [ -d "/home/node" ]; then
|
|
66
|
+
CONFIG_DIR="/home/node/.talk-to-code"
|
|
67
|
+
elif [ -d "$CURRENT_HOME" ]; then
|
|
68
|
+
CONFIG_DIR="$CURRENT_HOME/.talk-to-code"
|
|
69
|
+
else
|
|
70
|
+
# Fallback to /tmp if no home directory
|
|
71
|
+
CONFIG_DIR="/tmp/.talk-to-code"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
echo "Using config directory: $CONFIG_DIR"
|
|
75
|
+
mkdir -p "$CONFIG_DIR" || {
|
|
76
|
+
echo "WARNING: Could not create $CONFIG_DIR, trying /tmp/.talk-to-code"
|
|
77
|
+
CONFIG_DIR="/tmp/.talk-to-code"
|
|
78
|
+
mkdir -p "$CONFIG_DIR"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Configure API URL
|
|
82
|
+
echo "{\"apiUrl\":\"$TTC_SERVER_URL\"}" > "$CONFIG_DIR/config.json" || {
|
|
83
|
+
echo "ERROR: Could not write config file"
|
|
84
|
+
exit 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Generate unique deviceId for this container (if not provided)
|
|
88
|
+
DEVICE_ID_FILE="$CONFIG_DIR/device-id.json"
|
|
89
|
+
if [ ! -f "$DEVICE_ID_FILE" ]; then
|
|
90
|
+
# Generate UUID using Node.js
|
|
91
|
+
DEVICE_ID=$(node -e "const {randomUUID}=require('crypto');console.log(randomUUID())" 2>/dev/null) || {
|
|
92
|
+
echo "ERROR: Could not generate device ID"
|
|
93
|
+
exit 1
|
|
94
|
+
}
|
|
95
|
+
echo "{\"deviceId\":\"$DEVICE_ID\"}" > "$DEVICE_ID_FILE" || {
|
|
96
|
+
echo "ERROR: Could not write device ID file"
|
|
97
|
+
exit 1
|
|
98
|
+
}
|
|
99
|
+
echo "Generated device ID: $DEVICE_ID"
|
|
100
|
+
else
|
|
101
|
+
DEVICE_ID=$(node -e "console.log(require('$DEVICE_ID_FILE').deviceId)" 2>/dev/null) || {
|
|
102
|
+
echo "ERROR: Could not read device ID file"
|
|
103
|
+
exit 1
|
|
104
|
+
}
|
|
105
|
+
echo "Using existing device ID: $DEVICE_ID"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# Set container name from environment (if provided)
|
|
109
|
+
CONTAINER_NAME="${CONTAINER_NAME:-container}"
|
|
110
|
+
HOSTNAME="${HOSTNAME:-$CONTAINER_NAME}"
|
|
111
|
+
|
|
112
|
+
echo "Container name: $CONTAINER_NAME"
|
|
113
|
+
echo "Hostname: $HOSTNAME"
|
|
114
|
+
echo "Server URL: $TTC_SERVER_URL"
|
|
115
|
+
echo "Device ID: $DEVICE_ID"
|
|
116
|
+
echo ""
|
|
117
|
+
|
|
118
|
+
# Ensure common project directories exist
|
|
119
|
+
# This prevents ENOENT errors when SDK tries to spawn with these as cwd
|
|
120
|
+
# Create directories that work regardless of user (under /tmp)
|
|
121
|
+
COMMON_PROJECT_DIRS="/tmp/project /tmp/workspace /tmp/abc"
|
|
122
|
+
for dir in $COMMON_PROJECT_DIRS; do
|
|
123
|
+
if [ ! -d "$dir" ]; then
|
|
124
|
+
echo "Creating project directory: $dir"
|
|
125
|
+
mkdir -p "$dir" 2>/dev/null || echo "WARNING: Could not create $dir"
|
|
126
|
+
fi
|
|
127
|
+
done
|
|
128
|
+
|
|
129
|
+
# Create /home/abc if we're root (setup phase)
|
|
130
|
+
# This directory is commonly used as project path in containers
|
|
131
|
+
if [ "$(id -u)" = "0" ]; then
|
|
132
|
+
if [ ! -d "/home/abc" ]; then
|
|
133
|
+
echo "Creating /home/abc (as root)"
|
|
134
|
+
mkdir -p "/home/abc"
|
|
135
|
+
chown -R node:node "/home/abc"
|
|
136
|
+
echo "Created /home/abc with node:node ownership"
|
|
137
|
+
fi
|
|
138
|
+
else
|
|
139
|
+
# Not root - can't create in /home, but /tmp/abc is already created above
|
|
140
|
+
echo "Not running as root, /home/abc creation skipped (using /tmp/abc as fallback)"
|
|
141
|
+
fi
|
|
142
|
+
echo ""
|
|
143
|
+
|
|
144
|
+
# Change to CLI directory (use the directory containing the CLI file)
|
|
145
|
+
CLI_DIR=$(dirname "$CLI_FILE")
|
|
146
|
+
cd "$CLI_DIR" || {
|
|
147
|
+
echo "ERROR: Could not change to CLI directory: $CLI_DIR"
|
|
148
|
+
exit 1
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Verify we can read the CLI file
|
|
152
|
+
if [ ! -r "$CLI_FILE" ]; then
|
|
153
|
+
echo "ERROR: CLI file not readable: $CLI_FILE"
|
|
154
|
+
ls -la "$CLI_DIR/" || true
|
|
155
|
+
exit 1
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# Run TTC daemon (non-foreground mode for containers)
|
|
159
|
+
echo "Starting TTC CLI daemon..."
|
|
160
|
+
echo "Working directory: $(pwd)"
|
|
161
|
+
echo "CLI file: $CLI_FILE"
|
|
162
|
+
echo "User: $(id -un) ($(id -u))"
|
|
163
|
+
echo "HOME: $HOME"
|
|
164
|
+
echo "Node version: $(node --version)"
|
|
165
|
+
echo ""
|
|
166
|
+
|
|
167
|
+
# Execute the CLI daemon
|
|
168
|
+
# Note: exec replaces the shell process, so any code after this won't run
|
|
169
|
+
# If exec fails, the container will exit with the error code
|
|
170
|
+
|
|
171
|
+
# If running as root, switch to node user for security
|
|
172
|
+
if [ "$(id -u)" = "0" ]; then
|
|
173
|
+
echo "Switching to node user (UID 1000)..."
|
|
174
|
+
exec su-exec node:node node "$CLI_FILE" daemon
|
|
175
|
+
else
|
|
176
|
+
exec node "$CLI_FILE" daemon
|
|
177
|
+
fi
|