@icyfenix-dmla/cli 2026.4.19-920 → 2026.4.19-930

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icyfenix-dmla/cli",
3
- "version": "2026.4.19-920",
3
+ "version": "2026.4.19-930",
4
4
  "description": "DMLA 沙箱服务命令行工具",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -6,7 +6,7 @@ import Docker from 'dockerode'
6
6
  import { spawn } from 'child_process'
7
7
  import http from 'http'
8
8
  import path from 'path'
9
- import { fileURLToPath } from 'url'
9
+ import { fileURLToPath, pathToFileURL } from 'url'
10
10
  import fs from 'fs'
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url)
@@ -49,6 +49,41 @@ async function checkImageExists(type) {
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * 检查可用镜像并自动选择
54
+ * @returns {Object} { imageType: 'cpu'|'gpu', message: string }
55
+ */
56
+ async function resolveImageType(useGpu) {
57
+ const cpuExists = await checkImageExists('cpu')
58
+ const gpuExists = await checkImageExists('gpu')
59
+
60
+ // 用户明确指定了 GPU
61
+ if (useGpu) {
62
+ if (gpuExists) {
63
+ return { imageType: 'gpu', message: 'GPU' }
64
+ }
65
+ // 用户想要 GPU 但 GPU 镜像不存在
66
+ if (cpuExists) {
67
+ console.log(chalk.yellow('⚠️ GPU 镜像不存在,将使用 CPU 镜像'))
68
+ return { imageType: 'cpu', message: 'CPU (降级)' }
69
+ }
70
+ return { imageType: null, message: '无可用镜像' }
71
+ }
72
+
73
+ // 用户未指定,自动选择
74
+ if (cpuExists) {
75
+ return { imageType: 'cpu', message: 'CPU' }
76
+ }
77
+
78
+ // CPU 镜像不存在
79
+ if (gpuExists) {
80
+ console.log(chalk.yellow('⚠️ CPU 镜像不存在,自动使用 GPU 镜像'))
81
+ return { imageType: 'gpu', message: 'GPU (自动)' }
82
+ }
83
+
84
+ return { imageType: null, message: '无可用镜像' }
85
+ }
86
+
52
87
  /**
53
88
  * 检查 GPU 是否可用
54
89
  */
@@ -114,7 +149,107 @@ async function findServiceContainer() {
114
149
  }
115
150
 
116
151
  /**
117
- * 启动服务
152
+ * 查找服务器入口文件
153
+ */
154
+ function findServerPath() {
155
+ // 开发环境路径:packages/cli/src/commands -> ../../../local-server/src/index.js
156
+ const serverPath = path.resolve(__dirname, '../../../local-server/src/index.js')
157
+ // npm 包路径:packages/cli/src/commands -> ../server/index.js
158
+ const standaloneServerPath = path.resolve(__dirname, '../server/index.js')
159
+
160
+ // 检查 __dirname 是否正确(调试用)
161
+ const cliPackageRoot = path.resolve(__dirname, '../..')
162
+ const expectedServerDir = path.resolve(cliPackageRoot, 'src/server')
163
+
164
+ if (fs.existsSync(serverPath)) {
165
+ return serverPath
166
+ }
167
+
168
+ if (fs.existsSync(standaloneServerPath)) {
169
+ return standaloneServerPath
170
+ }
171
+
172
+ // 调试输出:显示路径信息帮助诊断
173
+ console.log(chalk.yellow('⚠️ 服务入口文件查找失败'))
174
+ console.log(chalk.gray(` __dirname: ${__dirname}`))
175
+ console.log(chalk.gray(` 开发路径: ${serverPath} (${fs.existsSync(serverPath) ? '存在' : '不存在'})`))
176
+ console.log(chalk.gray(` npm路径: ${standaloneServerPath} (${fs.existsSync(standaloneServerPath) ? '存在' : '不存在'})`))
177
+ console.log(chalk.gray(` CLI包根目录: ${cliPackageRoot}`))
178
+
179
+ // 检查 CLI 包根目录下的文件结构
180
+ const srcDir = path.resolve(cliPackageRoot, 'src')
181
+ if (fs.existsSync(srcDir)) {
182
+ console.log(chalk.gray(` src目录内容: ${fs.readdirSync(srcDir).join(', ')}`))
183
+ if (fs.existsSync(expectedServerDir)) {
184
+ console.log(chalk.gray(` server目录内容: ${fs.readdirSync(expectedServerDir).join(', ')}`))
185
+ }
186
+ }
187
+
188
+ return null
189
+ }
190
+
191
+ /**
192
+ * 同步启动服务(在当前进程运行,用于调试)
193
+ * @param {number} port - 服务端口
194
+ * @param {boolean} useGpu - 是否使用 GPU(可选,自动检测)
195
+ */
196
+ export async function startServerSync(port, useGpu = false) {
197
+ // 检查端口
198
+ const portAvailable = await checkPortAvailable(port)
199
+ if (!portAvailable) {
200
+ console.log(chalk.red(`❌ 端口 ${port} 已被占用`))
201
+ console.log(chalk.yellow('💡 提示: 使用 --port 选项指定其他端口'))
202
+ return
203
+ }
204
+
205
+ // 智能选择镜像
206
+ const imageResolution = await resolveImageType(useGpu)
207
+ if (!imageResolution.imageType) {
208
+ console.log(chalk.red('❌ 无可用镜像'))
209
+ console.log(chalk.yellow('💡 提示: 运行 dmla install 安装镜像'))
210
+ return
211
+ }
212
+ const resolvedUseGpu = imageResolution.imageType === 'gpu'
213
+
214
+ // 检查服务是否已运行
215
+ const alreadyRunning = await checkServiceRunning(port)
216
+ if (alreadyRunning) {
217
+ console.log(chalk.green(`✅ 服务已在端口 ${port} 运行`))
218
+ return
219
+ }
220
+
221
+ // 查找服务器入口
222
+ const actualServerPath = findServerPath()
223
+ if (!actualServerPath) {
224
+ console.log(chalk.red('❌ 找不到服务入口文件'))
225
+ console.log(chalk.yellow('💡 提示: 确保正确安装了 @icyfenix-dmla/cli'))
226
+ return
227
+ }
228
+
229
+ console.log(chalk.gray(` 镜像类型: ${imageResolution.message}`))
230
+ console.log(chalk.gray(' 同步模式启动...'))
231
+ console.log(chalk.gray(` 服务入口: ${actualServerPath}`))
232
+ console.log()
233
+
234
+ // 设置环境变量
235
+ process.env.PORT = port.toString()
236
+ process.env.USE_GPU = resolvedUseGpu ? 'true' : 'false'
237
+ process.env.DMLA_SYNC_MODE = 'true' // 标记同步模式,让服务器在 import 时启动
238
+
239
+ // 动态 import 服务器模块并直接运行
240
+ // 服务器模块会在 import 时自动启动(因为入口点检测逻辑)
241
+ // Windows 需要将路径转换为 file:// URL 格式
242
+ try {
243
+ const serverURL = pathToFileURL(actualServerPath).href
244
+ await import(serverURL)
245
+ } catch (error) {
246
+ console.log(chalk.red(`❌ 服务启动失败: ${error.message}`))
247
+ console.log(chalk.gray(error.stack))
248
+ }
249
+ }
250
+
251
+ /**
252
+ * 启动服务(异步模式,spawn 子进程)
118
253
  */
119
254
  export async function startServer(port, useGpu = false) {
120
255
  // 检查端口
@@ -125,14 +260,14 @@ export async function startServer(port, useGpu = false) {
125
260
  return
126
261
  }
127
262
 
128
- // 检查镜像
129
- const imageType = useGpu ? 'gpu' : 'cpu'
130
- const imageExists = await checkImageExists(imageType)
131
- if (!imageExists) {
132
- console.log(chalk.red(`❌ 镜像 ${useGpu ? CONFIG.imageGpu : CONFIG.imageCpu} 不存在`))
263
+ // 智能选择镜像
264
+ const imageResolution = await resolveImageType(useGpu)
265
+ if (!imageResolution.imageType) {
266
+ console.log(chalk.red('❌ 无可用镜像'))
133
267
  console.log(chalk.yellow('💡 提示: 运行 dmla install 安装镜像'))
134
268
  return
135
269
  }
270
+ const resolvedUseGpu = imageResolution.imageType === 'gpu'
136
271
 
137
272
  // 检查服务是否已运行
138
273
  const alreadyRunning = await checkServiceRunning(port)
@@ -142,17 +277,11 @@ export async function startServer(port, useGpu = false) {
142
277
  }
143
278
 
144
279
  // 启动服务
280
+ console.log(chalk.gray(` 镜像类型: ${imageResolution.message}`))
145
281
  console.log(chalk.gray(' 正在启动...'))
146
282
 
147
283
  try {
148
- // 使用 spawn 启动 server 进程
149
- const serverPath = path.resolve(__dirname, '../../../local-server/src/index.js')
150
-
151
- // 如果 server 文件不存在,说明是独立安装模式,需要启动内置服务
152
- const standaloneServerPath = path.resolve(__dirname, '../server/index.js')
153
-
154
- const actualServerPath = fs.existsSync(serverPath) ? serverPath :
155
- fs.existsSync(standaloneServerPath) ? standaloneServerPath : null
284
+ const actualServerPath = findServerPath()
156
285
 
157
286
  if (!actualServerPath) {
158
287
  console.log(chalk.red('❌ 找不到服务入口文件'))
@@ -160,20 +289,61 @@ export async function startServer(port, useGpu = false) {
160
289
  return
161
290
  }
162
291
 
292
+ // 日志文件路径
293
+ const logDir = path.resolve(__dirname, '../../logs')
294
+ if (!fs.existsSync(logDir)) {
295
+ fs.mkdirSync(logDir, { recursive: true })
296
+ }
297
+ const logFile = path.join(logDir, 'server.log')
298
+ const errorLogFile = path.join(logDir, 'server-error.log')
299
+
300
+ console.log(chalk.gray(` 日志文件: ${logFile}`))
301
+
302
+ // 创建日志文件流
303
+ const logStream = fs.openSync(logFile, 'a')
304
+ const errorLogStream = fs.openSync(errorLogFile, 'a')
305
+
163
306
  const env = {
164
307
  ...process.env,
165
308
  PORT: port.toString(),
166
- USE_GPU: useGpu ? 'true' : 'false'
309
+ USE_GPU: resolvedUseGpu ? 'true' : 'false',
310
+ DMLA_LOG_FILE: logFile // 传递日志文件路径给服务端
167
311
  }
168
312
 
313
+ // 写入启动日志
314
+ const timestamp = new Date().toISOString()
315
+ fs.writeSync(logStream, `[${timestamp}] Server starting...\n`)
316
+ fs.writeSync(logStream, `[${timestamp}] Server path: ${actualServerPath}\n`)
317
+ fs.writeSync(logStream, `[${timestamp}] Port: ${port}\n`)
318
+ fs.writeSync(logStream, `[${timestamp}] GPU: ${resolvedUseGpu} (${imageResolution.message})\n`)
319
+
320
+ // 使用 spawn 启动 server 进程
321
+ // 重要:stdio 必须是 'ignore' 或管道,不能是 'inherit'
322
+ // 因为 'inherit' 会让子进程依赖父进程的 stdout,父进程退出后子进程也会退出
169
323
  const serverProcess = spawn('node', [actualServerPath], {
170
324
  env,
171
- stdio: 'inherit',
172
- detached: true
325
+ stdio: ['ignore', logStream, errorLogStream], // stdin: ignore, stdout: log file, stderr: error log
326
+ detached: true,
327
+ windowsHide: true // Windows 下隐藏窗口
328
+ })
329
+
330
+ // 监听子进程事件(调试用)
331
+ serverProcess.on('error', (err) => {
332
+ fs.writeSync(errorLogStream, `[${new Date().toISOString()}] Spawn error: ${err.message}\n`)
333
+ })
334
+
335
+ serverProcess.on('exit', (code, signal) => {
336
+ const msg = `[${new Date().toISOString()}] Process exited: code=${code}, signal=${signal}\n`
337
+ fs.writeSync(logStream, msg)
338
+ fs.writeSync(errorLogStream, msg)
173
339
  })
174
340
 
175
341
  serverProcess.unref()
176
342
 
343
+ // 关闭父进程中的文件描述符(子进程会保留自己的副本)
344
+ fs.closeSync(logStream)
345
+ fs.closeSync(errorLogStream)
346
+
177
347
  // 等待服务启动
178
348
  console.log(chalk.gray(' 等待服务就绪...'))
179
349
  let attempts = 0
@@ -184,15 +354,19 @@ export async function startServer(port, useGpu = false) {
184
354
  if (running) {
185
355
  console.log(chalk.green(`✅ 服务已启动: http://localhost:${port}`))
186
356
  console.log(chalk.gray(` 健康检查: http://localhost:${port}/api/health`))
357
+ console.log(chalk.gray(` 日志查看: ${logFile}`))
187
358
  return
188
359
  }
189
360
  await new Promise(resolve => setTimeout(resolve, 500))
190
361
  attempts++
191
362
  }
192
363
 
193
- console.log(chalk.yellow('⚠️ 服务启动超时,请检查日志'))
364
+ console.log(chalk.yellow('⚠️ 服务启动超时'))
365
+ console.log(chalk.gray(` 请查看日志: ${logFile}`))
366
+ console.log(chalk.gray(` 或使用 --sync 模式调试`))
194
367
  } catch (error) {
195
368
  console.log(chalk.red(`❌ 启动失败: ${error.message}`))
369
+ console.log(chalk.gray(error.stack))
196
370
  }
197
371
  }
198
372
 
@@ -200,7 +374,51 @@ export async function startServer(port, useGpu = false) {
200
374
  * 停止服务
201
375
  */
202
376
  export async function stopServer() {
203
- // 查找运行中的容器
377
+ // 首先尝试通过 API 停止服务
378
+ const port = CONFIG.defaultPort
379
+ const running = await checkServiceRunning(port)
380
+
381
+ if (running) {
382
+ try {
383
+ // 调用 shutdown API
384
+ await new Promise((resolve, reject) => {
385
+ const req = http.request({
386
+ hostname: 'localhost',
387
+ port: port,
388
+ path: '/api/shutdown',
389
+ method: 'POST',
390
+ timeout: 5000
391
+ }, (res) => {
392
+ if (res.statusCode === 200) {
393
+ console.log(chalk.green('✅ 服务已停止'))
394
+ resolve()
395
+ } else {
396
+ reject(new Error(`HTTP ${res.statusCode}`))
397
+ }
398
+ })
399
+ req.on('error', (e) => reject(e))
400
+ req.on('timeout', () => {
401
+ req.destroy()
402
+ reject(new Error('Timeout'))
403
+ })
404
+ req.end()
405
+ })
406
+
407
+ // 等待服务完全关闭
408
+ let attempts = 0
409
+ while (attempts < 10) {
410
+ const stillRunning = await checkServiceRunning(port)
411
+ if (!stillRunning) break
412
+ await new Promise(r => setTimeout(r, 200))
413
+ attempts++
414
+ }
415
+ return
416
+ } catch (error) {
417
+ console.log(chalk.yellow(`⚠️ 通过 API 停止失败: ${error.message}`))
418
+ }
419
+ }
420
+
421
+ // 尝试查找并停止 Docker 容器
204
422
  const container = await findServiceContainer()
205
423
 
206
424
  if (container) {
@@ -208,14 +426,15 @@ export async function stopServer() {
208
426
  const containerObj = docker.getContainer(container.Id)
209
427
  await containerObj.stop()
210
428
  await containerObj.remove()
211
- console.log(chalk.green('✅ 服务已停止'))
429
+ console.log(chalk.green('✅ 服务容器已停止'))
212
430
  } catch (error) {
213
- console.log(chalk.red(`❌ 停止失败: ${error.message}`))
431
+ console.log(chalk.red(`❌ 停止容器失败: ${error.message}`))
214
432
  }
433
+ } else if (!running) {
434
+ console.log(chalk.gray(' 服务未运行'))
215
435
  } else {
216
- // 尝试通过端口查找进程
217
- console.log(chalk.yellow('⚠️ 未找到运行中的服务容器'))
218
- console.log(chalk.gray(' 提示: 服务可能以非容器模式运行'))
436
+ console.log(chalk.yellow('⚠️ 无法停止服务'))
437
+ console.log(chalk.gray(' 提示: 手动终止端口 3001 上的进程'))
219
438
  }
220
439
  }
221
440
 
@@ -228,7 +447,8 @@ export async function getStatus() {
228
447
  // 检查 npm 包版本
229
448
  console.log(chalk.bold('📦 npm 包版本'))
230
449
  try {
231
- const pkgPath = path.resolve(__dirname, '../package.json')
450
+ // __dirname src/commands,需要向上两级到包根目录
451
+ const pkgPath = path.resolve(__dirname, '../../package.json')
232
452
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
233
453
  console.log(chalk.gray(` @icyfenix-dmla/cli: ${pkg.version}`))
234
454
  } catch {
package/src/index.js CHANGED
@@ -2,18 +2,87 @@
2
2
  * DMLA CLI 入口
3
3
  * 沙箱服务命令行管理工具
4
4
  */
5
- import { program } from 'commander'
5
+ import { program, Help } from 'commander'
6
6
  import chalk from 'chalk'
7
- import { startServer, stopServer, getStatus } from './commands/server.js'
7
+ import path from 'path'
8
+ import { fileURLToPath } from 'url'
9
+ import fs from 'fs'
10
+ import { startServer, startServerSync, stopServer, getStatus } from './commands/server.js'
8
11
  import { updateAll, runDoctor } from './commands/manage.js'
9
12
  import { runInstallTUI } from '@icyfenix-dmla/install'
10
13
 
11
- const VERSION = '0.0.0' // 将在发布时由 workflow 更新
14
+ // package.json 读取版本号
15
+ const __filename = fileURLToPath(import.meta.url)
16
+ const __dirname = path.dirname(__filename)
17
+ const pkgPath = path.resolve(__dirname, '../package.json')
18
+ const VERSION = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
19
+
20
+ // 重写 Help 类的方法以输出中文标题
21
+ Help.prototype.padWidth = function(cmd, helper) {
22
+ return 20
23
+ }
24
+
25
+ // 重写帮助信息格式化方法
26
+ Help.prototype.formatHelp = function(cmd, helper) {
27
+ const indent = ' '
28
+ const itemIndent = ' '
29
+
30
+ let output = []
31
+
32
+ // 用法(中文)
33
+ output.push('用法:')
34
+ output.push(indent + helper.commandUsage(cmd))
35
+
36
+ // 说明(中文)
37
+ if (cmd.description()) {
38
+ output.push('')
39
+ output.push('说明:')
40
+ output.push(indent + cmd.description())
41
+ }
42
+
43
+ // 参数(中文)
44
+ const args = helper.visibleArguments(cmd)
45
+ if (args.length > 0) {
46
+ output.push('')
47
+ output.push('参数:')
48
+ args.forEach(arg => {
49
+ output.push(itemIndent + arg.name())
50
+ })
51
+ }
52
+
53
+ // 选项(中文)
54
+ const options = helper.visibleOptions(cmd)
55
+ if (options.length > 0) {
56
+ output.push('')
57
+ output.push('选项:')
58
+ options.forEach(opt => {
59
+ const term = helper.optionTerm(opt)
60
+ const description = helper.optionDescription(opt)
61
+ output.push(itemIndent + term.padEnd(20) + description)
62
+ })
63
+ }
64
+
65
+ // 命令(中文)
66
+ const commands = helper.visibleCommands(cmd)
67
+ if (commands.length > 0) {
68
+ output.push('')
69
+ output.push('命令:')
70
+ commands.forEach(subcmd => {
71
+ const term = helper.subcommandTerm(subcmd)
72
+ const description = helper.subcommandDescription(subcmd)
73
+ output.push(itemIndent + term.padEnd(20) + description)
74
+ })
75
+ }
76
+
77
+ return output.join('\n')
78
+ }
12
79
 
13
80
  program
14
81
  .name('dmla')
15
82
  .description('DMLA 沙箱服务命令行管理工具')
16
- .version(VERSION)
83
+ .version(VERSION, '-v, --version', '显示版本号')
84
+ .helpOption('-h, --help', '显示帮助信息')
85
+ .addHelpCommand('help [command]', '显示命令帮助信息')
17
86
 
18
87
  // ─────────────────────────────────────────────────────────────
19
88
  // start 命令
@@ -23,13 +92,24 @@ program
23
92
  .description('启动沙箱服务')
24
93
  .option('-p, --port <number>', '服务端口', '3001')
25
94
  .option('--gpu', '使用 GPU 镜像')
95
+ .option('--sync', '同步模式:在当前进程运行,日志直接输出(用于调试)')
26
96
  .action(async (options) => {
27
97
  const port = parseInt(options.port, 10)
28
98
  const useGpu = options.gpu
99
+ const sync = options.sync
100
+
29
101
  console.log(chalk.blue('🚀 启动 DMLA 沙箱服务...'))
30
102
  console.log(chalk.gray(` 端口: ${port}`))
31
- console.log(chalk.gray(` 镜像: ${useGpu ? 'GPU' : 'CPU'}`))
32
- await startServer(port, useGpu)
103
+ console.log(chalk.gray(` 请求类型: ${useGpu ? 'GPU' : '自动选择'}`))
104
+ if (sync) {
105
+ console.log(chalk.yellow(` 模式: 同步(调试模式)`))
106
+ }
107
+
108
+ if (sync) {
109
+ await startServerSync(port, useGpu)
110
+ } else {
111
+ await startServer(port, useGpu)
112
+ }
33
113
  })
34
114
 
35
115
  // ─────────────────────────────────────────────────────────────
@@ -4,39 +4,114 @@
4
4
  */
5
5
  import express from 'express'
6
6
  import cors from 'cors'
7
+ import { fileURLToPath } from 'url'
8
+ import { resolve } from 'path'
7
9
  import sandboxRouter from './routes/sandbox.js'
8
10
 
9
11
  export const app = express()
10
12
  const PORT = process.env.PORT || 3001
11
13
 
14
+ // 日志函数
15
+ function log(message) {
16
+ const timestamp = new Date().toISOString()
17
+ console.log(`[${timestamp}] ${message}`)
18
+ }
19
+
20
+ // 启动日志
21
+ log('Server initializing...')
22
+ log(`PORT: ${PORT}`)
23
+ log(`NODE_VERSION: ${process.version}`)
24
+ log(`PLATFORM: ${process.platform}`)
25
+ log(`DMLA_SYNC_MODE: ${process.env.DMLA_SYNC_MODE || 'false'}`)
26
+
12
27
  // 中间件
13
28
  app.use(cors())
14
29
  app.use(express.json())
15
30
 
31
+ // 请求日志中间件
32
+ app.use((req, res, next) => {
33
+ log(`Request: ${req.method} ${req.path}`)
34
+ next()
35
+ })
36
+
16
37
  // 健康检查
17
38
  app.get('/api/health', (req, res) => {
39
+ log('Health check request')
18
40
  res.json({ status: 'ok', timestamp: new Date().toISOString() })
19
41
  })
20
42
 
43
+ // 停止服务(用于 CLI stop 命令)
44
+ app.post('/api/shutdown', (req, res) => {
45
+ log('Shutdown request received')
46
+ res.json({ status: 'shutting_down', timestamp: new Date().toISOString() })
47
+ // 延迟关闭,确保响应发送完成
48
+ setTimeout(() => {
49
+ log('Exiting due to shutdown request')
50
+ process.exit(0)
51
+ }, 100)
52
+ })
53
+
21
54
  // 沙箱 API
22
55
  app.use('/api/sandbox', sandboxRouter)
23
56
 
24
57
  // 错误处理
25
58
  app.use((err, req, res, next) => {
26
- console.error('Error:', err)
59
+ log(`Error: ${err.message}`)
60
+ log(`Stack: ${err.stack}`)
27
61
  res.status(500).json({
28
62
  success: false,
29
63
  error: err.message || 'Internal Server Error'
30
64
  })
31
65
  })
32
66
 
33
- // 仅在直接运行时启动服务器(测试时不启动)
34
- if (import.meta.url === `file://${process.argv[1]}`) {
35
- app.listen(PORT, () => {
36
- console.log(`🚀 DMLA 本地服务已启动`)
37
- console.log(` API: http://localhost:${PORT}`)
38
- console.log(` 健康检查: http://localhost:${PORT}/api/health`)
67
+ // 捕获未处理的异常
68
+ process.on('uncaughtException', (err) => {
69
+ log(`UNCAUGHT EXCEPTION: ${err.message}`)
70
+ log(`Stack: ${err.stack}`)
71
+ process.exit(1)
72
+ })
73
+
74
+ // 捕获未处理的 Promise 拒绝
75
+ process.on('unhandledRejection', (reason, promise) => {
76
+ log(`UNHANDLED REJECTION: ${reason}`)
77
+ })
78
+
79
+ // 捕获进程信号
80
+ process.on('SIGTERM', () => {
81
+ log('Received SIGTERM')
82
+ process.exit(0)
83
+ })
84
+
85
+ process.on('SIGINT', () => {
86
+ log('Received SIGINT')
87
+ process.exit(0)
88
+ })
89
+
90
+ // 启动服务器
91
+ // 条件1: 直接运行(入口点匹配)
92
+ // 条件2: 同步模式(DMLA_SYNC_MODE 环境变量)
93
+ const __filename = fileURLToPath(import.meta.url)
94
+ const entryPoint = resolve(process.argv[1] || '')
95
+ const shouldStart = __filename === entryPoint || process.env.DMLA_SYNC_MODE === 'true'
96
+
97
+ log(`Entry point check: __filename=${__filename}, entryPoint=${entryPoint}, match=${__filename === entryPoint}`)
98
+
99
+ if (shouldStart) {
100
+ const server = app.listen(PORT, () => {
101
+ log('Server started successfully')
102
+ log(`API: http://localhost:${PORT}`)
103
+ log(`Health: http://localhost:${PORT}/api/health`)
104
+ })
105
+
106
+ server.on('error', (err) => {
107
+ log(`Server error: ${err.message}`)
108
+ })
109
+
110
+ server.on('close', () => {
111
+ log('Server closed')
39
112
  })
113
+ } else {
114
+ log('Skipping server start (imported as module)')
40
115
  }
41
116
 
42
117
  export default app
@@ -2,7 +2,9 @@
2
2
  * 沙箱 API 路由
3
3
  */
4
4
  import { Router } from 'express'
5
- import { runPythonCode, checkImageExists, checkGPUAvailable } from '../sandbox.js'
5
+ import sandbox, { runPythonCode, checkImageExists, checkGPUAvailable } from '../sandbox.js'
6
+
7
+ const { SANDBOX_CONFIG } = sandbox
6
8
 
7
9
  const router = Router()
8
10
 
@@ -56,31 +58,45 @@ router.post('/run', async (req, res) => {
56
58
  }
57
59
 
58
60
  try {
59
- // 检查镜像是否存在
60
- const imageExists = await checkImageExists(useGpu)
61
+ // 检查镜像是否存在,智能降级
62
+ // GPU镜像包含CPU的全部功能,如果CPU镜像不存在但GPU镜像存在,可以使用GPU镜像执行CPU代码
63
+ let actualUseGpu = useGpu
64
+ let actualImage = null // 指定使用的镜像
65
+ let imageExists = await checkImageExists(useGpu)
66
+
67
+ if (!imageExists && !useGpu) {
68
+ // CPU镜像不存在,检查是否可以用GPU镜像替代
69
+ const gpuImageExists = await checkImageExists(true)
70
+ if (gpuImageExists) {
71
+ imageExists = true
72
+ actualUseGpu = false // 不启用GPU设备
73
+ actualImage = SANDBOX_CONFIG.imageGpu // 使用GPU镜像
74
+ console.log('[Sandbox] CPU镜像不存在,使用GPU镜像执行(不启用GPU设备)')
75
+ }
76
+ }
61
77
 
62
78
  if (!imageExists) {
63
79
  return res.status(503).json({
64
80
  success: false,
65
81
  error: useGpu
66
82
  ? 'GPU 镜像未安装。请运行以下命令安装:\n\nnpm run build:sandbox:gpu\n\n或使用 dmla CLI:\n\ndmla install --gpu'
67
- : '沙箱镜像未安装。请运行以下命令安装:\n\nnpm run build:sandbox:cpu\n\n或使用 dmla CLI:\n\ndmla install --cpu'
83
+ : '沙箱镜像未安装。请运行以下命令安装:\n\nnpm run build:sandbox:cpu\n\n或使用 dmla CLI:\n\ndmla install --cpu\n\n注意:如果您已安装 GPU 镜像,它也支持 CPU 执行'
68
84
  })
69
85
  }
70
86
 
71
87
  // 如果请求 GPU,检查 GPU 是否可用
72
- if (useGpu) {
88
+ if (actualUseGpu) {
73
89
  const gpuAvailable = await checkGPUAvailable()
74
90
  if (!gpuAvailable) {
75
91
  return res.status(503).json({
76
92
  success: false,
77
- error: 'GPU 硬件不可用。请确保系统安装了 NVIDIA GPU 驱动和 nvidia-container-toolkit。\n\n诊断步骤:\n1. 运行 nvidia-smi 检查 GPU 状态\n2. 运行 docker run --rm --gpus all nvidia/cuda:11.8-base nvidia-smi 测试 Docker GPU 支持\n\n或使用 dmla doctor 进行环境诊断'
93
+ error: `GPU 硬件不可用。请确保系统安装了 NVIDIA GPU 驱动和 nvidia-container-toolkit。\n\n诊断步骤:\n1. 运行 nvidia-smi 检查 GPU 状态\n2. 运行 docker run --rm --gpus all ${SANDBOX_CONFIG.imageGpu} nvidia-smi 测试 Docker GPU 支持\n\n或使用 dmla doctor 进行环境诊断`
78
94
  })
79
95
  }
80
96
  }
81
97
 
82
- // 执行代码
83
- const result = await runPythonCode(code, useGpu)
98
+ // 执行代码(使用确定后的镜像)
99
+ const result = await runPythonCode(code, actualUseGpu, actualImage)
84
100
 
85
101
  res.json(result)
86
102
 
@@ -10,6 +10,15 @@ import fs from 'fs'
10
10
  const __filename = fileURLToPath(import.meta.url)
11
11
  const __dirname = path.dirname(__filename)
12
12
 
13
+ // 日志函数
14
+ function log(message) {
15
+ const timestamp = new Date().toISOString()
16
+ console.log(`[${timestamp}] [Sandbox] ${message}`)
17
+ }
18
+
19
+ // 启动时记录
20
+ log('Sandbox module initialized')
21
+
13
22
  // 检测运行模式并计算正确的路径
14
23
  // 开发模式: 从 local-server/src 运行,项目根目录在上两级
15
24
  // 独立模式: 从 packages/cli/src/server 运行,无 shared_modules 目录
@@ -39,6 +48,11 @@ const DEFAULT_SHARED_MODULES_PATH = PROJECT_ROOT
39
48
  ? path.join(PROJECT_ROOT, 'local-server', 'shared_modules')
40
49
  : null
41
50
 
51
+ // kernel_runner.py 路径(开发模式下可用)
52
+ const DEFAULT_KERNEL_RUNNER_PATH = PROJECT_ROOT
53
+ ? path.join(PROJECT_ROOT, 'local-server', 'src', 'kernel_runner.py')
54
+ : null
55
+
42
56
  const docker = new Docker()
43
57
 
44
58
  // 沙箱配置
@@ -61,6 +75,18 @@ function getSharedModulesPath() {
61
75
  return DEFAULT_SHARED_MODULES_PATH
62
76
  }
63
77
 
78
+ /**
79
+ * 获取 kernel_runner.py 路径
80
+ */
81
+ function getKernelRunnerPath() {
82
+ // 优先使用环境变量指定的路径
83
+ if (process.env.KERNEL_RUNNER_PATH) {
84
+ return process.env.KERNEL_RUNNER_PATH
85
+ }
86
+ // 开发模式下的默认路径
87
+ return DEFAULT_KERNEL_RUNNER_PATH
88
+ }
89
+
64
90
  /**
65
91
  * 检查是否启用 Volume Mount
66
92
  */
@@ -68,17 +94,24 @@ function shouldMountSharedModules() {
68
94
  return process.env.MOUNT_SHARED_MODULES !== 'false'
69
95
  }
70
96
 
97
+ /**
98
+ * 检查是否挂载本地 kernel_runner.py(开发模式)
99
+ */
100
+ function shouldMountKernelRunner() {
101
+ return process.env.MOUNT_KERNEL_RUNNER !== 'false' && PROJECT_ROOT !== null
102
+ }
103
+
71
104
  /**
72
105
  * 检查 GPU 是否可用
73
- * 通过运行 nvidia-smi 命令检测 GPU 状态
106
+ * 使用已安装的 GPU 镜像运行 nvidia-smi 命令检测 GPU 状态
74
107
  */
75
108
  export async function checkGPUAvailable() {
76
109
  let container = null
77
110
 
78
111
  try {
79
- // 创建容器运行 nvidia-smi 命令
112
+ // 使用已配置的 GPU 镜像检测,而非硬编码的 nvidia/cuda 镜像
80
113
  container = await docker.createContainer({
81
- Image: 'nvidia/cuda:11.8-base',
114
+ Image: SANDBOX_CONFIG.imageGpu,
82
115
  Cmd: ['nvidia-smi', '-L'],
83
116
  HostConfig: {
84
117
  DeviceRequests: [{
@@ -125,14 +158,18 @@ export async function checkGPUAvailable() {
125
158
  * 执行 Python 代码
126
159
  * 使用 IPython Kernel 执行代码,支持富输出(图片、文本、错误等)
127
160
  * @param {string} code - Python 代码
128
- * @param {boolean} useGpu - 是否使用 GPU
161
+ * @param {boolean} useGpu - 是否启用 GPU 设备
162
+ * @param {string|null} imageOverride - 可选,指定使用的镜像名称(覆盖默认选择)
129
163
  * @returns {Promise<{success: boolean, outputs: Array, executionTime: number, gpuUsed: boolean}>}
130
164
  */
131
- export async function runPythonCode(code, useGpu = false) {
165
+ export async function runPythonCode(code, useGpu = false, imageOverride = null) {
132
166
  const startTime = Date.now()
133
167
 
134
- // 选择镜像
135
- const image = useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu
168
+ log(`runPythonCode called, useGpu=${useGpu}, code length=${code.length}, imageOverride=${imageOverride}`)
169
+
170
+ // 选择镜像:优先使用指定的镜像,否则根据 useGpu 选择
171
+ const image = imageOverride || (useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu)
172
+ log(`Using image: ${image}`)
136
173
 
137
174
  // 创建容器配置 - 使用 kernel_runner.py 执行代码
138
175
  const containerConfig = {
@@ -148,25 +185,40 @@ export async function runPythonCode(code, useGpu = false) {
148
185
  ]
149
186
  }
150
187
 
151
- // Volume Mount 配置 - 挂载共享模块
188
+ log('Container config created')
189
+
190
+ // Volume Mount 配置
152
191
  const useMount = shouldMountSharedModules()
153
192
  const sharedModulesPath = getSharedModulesPath()
193
+ const mountKernelRunner = shouldMountKernelRunner()
194
+ const kernelRunnerPath = getKernelRunnerPath()
195
+
196
+ // 收集所有需要挂载的路径
197
+ const binds = []
198
+
199
+ // 挂载共享模块
200
+ if (useMount && sharedModulesPath && fs.existsSync(sharedModulesPath)) {
201
+ binds.push(`${sharedModulesPath}:/usr/local/lib/python3.11/site-packages/shared:ro`)
202
+ console.log(`[Sandbox] 共享模块 Volume Mount: ${sharedModulesPath}`)
203
+ } else if (useMount && sharedModulesPath) {
204
+ console.warn(`[Sandbox] 警告: 共享模块目录不存在: ${sharedModulesPath}`)
205
+ }
154
206
 
155
- if (useMount && sharedModulesPath) {
156
- // 检查共享模块目录是否存在
157
- if (fs.existsSync(sharedModulesPath)) {
158
- containerConfig.HostConfig.Binds = [
159
- `${sharedModulesPath}:/usr/local/lib/python3.11/site-packages/shared:ro`
160
- ]
161
- console.log(`[Sandbox] Volume Mount 已启用: ${sharedModulesPath}`)
162
- } else {
163
- console.warn(`[Sandbox] 警告: 共享模块目录不存在: ${sharedModulesPath}`)
164
- console.warn('[Sandbox] 提示: 运行 npm run extract:shared 生成共享模块')
165
- }
166
- } else if (!sharedModulesPath) {
167
- console.log('[Sandbox] 独立安装模式,无共享模块目录')
168
- } else {
169
- console.log('[Sandbox] Volume Mount 已禁用 (MOUNT_SHARED_MODULES=false)')
207
+ // 挂载 kernel_runner.py(开发模式调试)
208
+ if (mountKernelRunner && kernelRunnerPath && fs.existsSync(kernelRunnerPath)) {
209
+ binds.push(`${kernelRunnerPath}:/workspace/kernel_runner.py:ro`)
210
+ console.log(`[Sandbox] kernel_runner.py Volume Mount: ${kernelRunnerPath}`)
211
+ } else if (mountKernelRunner && kernelRunnerPath) {
212
+ console.warn(`[Sandbox] 警告: kernel_runner.py 不存在: ${kernelRunnerPath}`)
213
+ }
214
+
215
+ // 设置 Binds
216
+ if (binds.length > 0) {
217
+ containerConfig.HostConfig.Binds = binds
218
+ }
219
+
220
+ if (!PROJECT_ROOT) {
221
+ console.log('[Sandbox] 独立安装模式,无 Volume Mount')
170
222
  }
171
223
 
172
224
  // GPU 配置
@@ -183,41 +235,80 @@ export async function runPythonCode(code, useGpu = false) {
183
235
 
184
236
  try {
185
237
  // 创建容器
238
+ log('Creating container...')
186
239
  container = await docker.createContainer(containerConfig)
240
+ log(`Container created: ${container.id}`)
187
241
 
188
242
  // 设置超时
189
243
  const timeoutPromise = new Promise((_, reject) => {
190
244
  timeoutId = setTimeout(() => {
245
+ log('Execution timeout triggered')
191
246
  reject(new Error('Execution timeout'))
192
247
  }, SANDBOX_CONFIG.timeout + 10000) // 额外 10 秒用于清理
193
248
  })
194
249
 
195
250
  // 启动容器
251
+ log('Starting container...')
196
252
  await container.start()
253
+ log('Container started')
197
254
 
198
255
  // 等待执行完成
256
+ log('Waiting for container to finish...')
199
257
  const waitPromise = container.wait()
200
258
 
201
259
  // 竞速: 超时 vs 正常完成
202
260
  const result = await Promise.race([waitPromise, timeoutPromise])
261
+ log(`Container finished, result: ${JSON.stringify(result)}`)
203
262
 
204
263
  // 清除超时
205
264
  if (timeoutId) clearTimeout(timeoutId)
206
265
 
207
266
  // 获取输出
267
+ log('Getting container logs...')
208
268
  const logs = await container.logs({
209
269
  stdout: true,
210
270
  stderr: true
211
271
  })
212
272
 
213
- // 解析输出
214
- const rawOutput = parseDockerLogs(logs)
273
+ // 解析输出 - 分别处理 stdout 和 stderr
274
+ const { stdout, stderr } = parseDockerLogsSeparate(logs)
275
+ log(`Stdout length: ${stdout.length}`)
276
+ log(`Stderr length: ${stderr.length}`)
277
+
278
+ // stderr 可能包含调试信息
279
+ if (stderr.length > 0) {
280
+ log(`Stderr content preview: ${stderr.substring(0, 500)}`)
281
+ }
282
+
283
+ // stdout 可能包含 CUDA banner + JSON,需要提取 JSON 部分
284
+ // 找到第一个 '{' 作为 JSON 开始
285
+ const jsonStart = stdout.indexOf('{')
286
+ if (jsonStart === -1) {
287
+ log(`No JSON found in stdout`)
288
+ const executionTime = (Date.now() - startTime) / 1000
289
+ return {
290
+ success: false,
291
+ outputs: [{
292
+ type: 'error',
293
+ ename: 'OutputParseError',
294
+ evalue: 'No JSON output found',
295
+ traceback: [stdout.substring(0, 1000)]
296
+ }],
297
+ executionTime,
298
+ gpuUsed: useGpu
299
+ }
300
+ }
301
+
302
+ const rawOutput = stdout.substring(jsonStart)
303
+ log(`JSON extracted from position ${jsonStart}, length: ${rawOutput.length}`)
215
304
 
216
305
  // 解析 JSON 输出
217
306
  let parsedResult
218
307
  try {
219
308
  parsedResult = JSON.parse(rawOutput)
309
+ log('Output parsed successfully')
220
310
  } catch (parseError) {
311
+ log(`JSON parse error: ${parseError.message}`)
221
312
  // 如果 JSON 解析失败,返回原始输出作为错误
222
313
  const executionTime = (Date.now() - startTime) / 1000
223
314
  return {
@@ -241,6 +332,8 @@ export async function runPythonCode(code, useGpu = false) {
241
332
  }
242
333
 
243
334
  } catch (error) {
335
+ log(`Execution error: ${error.message}`)
336
+ log(`Error stack: ${error.stack}`)
244
337
  // 清除超时
245
338
  if (timeoutId) clearTimeout(timeoutId)
246
339
 
@@ -260,11 +353,13 @@ export async function runPythonCode(code, useGpu = false) {
260
353
 
261
354
  } finally {
262
355
  // 清理容器
356
+ log('Cleaning up container...')
263
357
  if (container) {
264
358
  try {
265
359
  await container.remove({ force: true })
360
+ log('Container removed')
266
361
  } catch (e) {
267
- console.warn('Failed to remove container:', e.message)
362
+ log(`Container cleanup error: ${e.message}`)
268
363
  }
269
364
  }
270
365
  }
@@ -305,6 +400,49 @@ function parseDockerLogs(logs) {
305
400
  return logs.toString()
306
401
  }
307
402
 
403
+ /**
404
+ * 解析 Docker 日志输出,分别返回 stdout 和 stderr
405
+ * Docker 日志格式: [8字节头][数据]
406
+ * 头部第一个字节: 0=stdin, 1=stdout, 2=stderr
407
+ */
408
+ function parseDockerLogsSeparate(logs) {
409
+ if (!logs || logs.length === 0) return { stdout: '', stderr: '' }
410
+
411
+ // 如果是 Buffer
412
+ if (Buffer.isBuffer(logs)) {
413
+ let stdout = ''
414
+ let stderr = ''
415
+ let offset = 0
416
+
417
+ while (offset < logs.length) {
418
+ // 跳过 8 字节头
419
+ if (offset + 8 > logs.length) break
420
+
421
+ const streamType = logs[offset] // 1=stdout, 2=stderr
422
+ const length = logs.readUInt32BE(offset + 4)
423
+
424
+ offset += 8
425
+
426
+ if (offset + length > logs.length) break
427
+
428
+ const chunk = logs.slice(offset, offset + length).toString('utf8')
429
+
430
+ if (streamType === 1) {
431
+ stdout += chunk
432
+ } else if (streamType === 2) {
433
+ stderr += chunk
434
+ }
435
+
436
+ offset += length
437
+ }
438
+
439
+ return { stdout, stderr }
440
+ }
441
+
442
+ // 如果是字符串,无法区分,全部作为 stdout
443
+ return { stdout: logs.toString(), stderr: '' }
444
+ }
445
+
308
446
  /**
309
447
  * 检查沙箱镜像是否存在
310
448
  */
@@ -347,5 +485,6 @@ export default {
347
485
  runPythonCode,
348
486
  checkGPUAvailable,
349
487
  checkImageExists,
350
- pullImage
488
+ pullImage,
489
+ SANDBOX_CONFIG
351
490
  }