@icyfenix-dmla/cli 2026.4.18-957 → 2026.4.19-1028

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 ADDED
@@ -0,0 +1,46 @@
1
+ # @icyfenix-dmla/cli
2
+
3
+ DMLA 沙箱服务命令行工具。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install -g @icyfenix-dmla/cli
9
+ ```
10
+
11
+ ## 使用
12
+
13
+ ```bash
14
+ # 启动服务
15
+ dmla start # 默认端口 3001
16
+ dmla start --port 8080 # 自定义端口
17
+ dmla start --gpu # GPU 模式
18
+
19
+ # 停止服务
20
+ dmla stop
21
+
22
+ # 查看状态
23
+ dmla status
24
+
25
+ # 安装镜像
26
+ dmla install # 安装所有镜像(默认从 Docker Hub)
27
+ dmla install --cpu # 仅 CPU 版本
28
+ dmla install --gpu # 仅 GPU 版本
29
+ dmla install --registry acr # 从阿里云 ACR 安装(国内加速)
30
+
31
+ # 更新
32
+ dmla update # 更新 npm 包和镜像
33
+ dmla update --registry acr
34
+
35
+ # 环境诊断
36
+ dmla doctor
37
+ ```
38
+
39
+ ## 要求
40
+
41
+ - Node.js >= 18.0.0
42
+ - Docker
43
+
44
+ ## 许可证
45
+
46
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icyfenix-dmla/cli",
3
- "version": "2026.4.18-957",
3
+ "version": "2026.4.19-1028",
4
4
  "description": "DMLA 沙箱服务命令行工具",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -9,15 +9,24 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node src/index.js",
12
+ "build": "node scripts/build.js",
13
+ "prepublishOnly": "npm run build",
12
14
  "test": "node --experimental-vm-modules $(npm root)/jest/bin/jest.js"
13
15
  },
16
+ "files": [
17
+ "bin/",
18
+ "src/",
19
+ "scripts/",
20
+ "README.md"
21
+ ],
14
22
  "dependencies": {
15
23
  "commander": "^12.1.0",
16
24
  "chalk": "^5.3.0",
17
25
  "enquirer": "^2.4.1",
18
26
  "dockerode": "^4.0.2",
19
27
  "express": "^4.21.2",
20
- "cors": "^2.8.5"
28
+ "cors": "^2.8.5",
29
+ "@icyfenix-dmla/install": "*"
21
30
  },
22
31
  "devDependencies": {
23
32
  "jest": "^29.7.0"
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 构建脚本:将 local-server 代码复制到 CLI 包中
3
+ * 用于 npm 发布时包含完整的服务器代码
4
+ */
5
+ import fs from 'fs'
6
+ import path from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = path.dirname(__filename)
11
+
12
+ const rootDir = path.resolve(__dirname, '../../..')
13
+ const localServerSrc = path.resolve(rootDir, 'local-server/src')
14
+ const cliServerDest = path.resolve(__dirname, '../src/server')
15
+
16
+ console.log('📦 构建 CLI 包...')
17
+ console.log(` 源目录: ${localServerSrc}`)
18
+ console.log(` 目标目录: ${cliServerDest}`)
19
+
20
+ // 递归复制目录
21
+ function copyDir(src, dest) {
22
+ if (!fs.existsSync(src)) {
23
+ console.error(`❌ 源目录不存在: ${src}`)
24
+ process.exit(1)
25
+ }
26
+
27
+ // 创建目标目录
28
+ if (!fs.existsSync(dest)) {
29
+ fs.mkdirSync(dest, { recursive: true })
30
+ }
31
+
32
+ const entries = fs.readdirSync(src, { withFileTypes: true })
33
+
34
+ for (const entry of entries) {
35
+ const srcPath = path.join(src, entry.name)
36
+ const destPath = path.join(dest, entry.name)
37
+
38
+ if (entry.isDirectory()) {
39
+ copyDir(srcPath, destPath)
40
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
41
+ fs.copyFileSync(srcPath, destPath)
42
+ console.log(` ✓ 复制: ${entry.name}`)
43
+ }
44
+ }
45
+ }
46
+
47
+ // 执行复制
48
+ copyDir(localServerSrc, cliServerDest)
49
+
50
+ console.log('✅ 服务器代码已复制到 CLI 包')
@@ -19,7 +19,7 @@ const CONFIG = {
19
19
  imageCpu: 'dmla-sandbox:cpu',
20
20
  imageGpu: 'dmla-sandbox:gpu',
21
21
  dockerhubRegistry: 'icyfenix',
22
- tcrRegistry: 'ccr.ccs.tencentyun.com/icyfenix',
22
+ acrRegistry: 'crpi-aani1ibpows293b8.cn-hangzhou.personal.cr.aliyuncs.com/fenixsoft',
23
23
  imageName: 'dmla-sandbox',
24
24
  defaultPort: 3001
25
25
  }
@@ -28,8 +28,8 @@ const CONFIG = {
28
28
  * 获取镜像仓库地址
29
29
  */
30
30
  function getRegistryUrl(registry) {
31
- if (registry === 'tcr') {
32
- return `${CONFIG.tcrRegistry}/${CONFIG.imageName}`
31
+ if (registry === 'acr') {
32
+ return `${CONFIG.acrRegistry}/${CONFIG.imageName}`
33
33
  }
34
34
  return `${CONFIG.dockerhubRegistry}/${CONFIG.imageName}`
35
35
  }
@@ -40,7 +40,7 @@ function getRegistryUrl(registry) {
40
40
  export async function installImages(types, registry = 'dockerhub') {
41
41
  const registryUrl = getRegistryUrl(registry)
42
42
 
43
- console.log(chalk.gray(` 从 ${registry === 'tcr' ? '腾讯云 TCR' : 'Docker Hub'} 拉取镜像`))
43
+ console.log(chalk.gray(` 从 ${registry === 'acr' ? '阿里云 ACR' : 'Docker Hub'} 拉取镜像`))
44
44
 
45
45
  for (const type of types) {
46
46
  console.log()
@@ -289,13 +289,13 @@ export async function runDoctor() {
289
289
  console.log(chalk.yellow(' ⚠️ Docker Hub 连接超时或受限'))
290
290
  }
291
291
 
292
- // 测试 TCR
293
- console.log(chalk.gray(' 测试腾讯云 TCR 连接...'))
292
+ // 测试 ACR
293
+ console.log(chalk.gray(' 测试阿里云 ACR 连接...'))
294
294
  try {
295
- execSync('docker pull ccr.ccs.tencentyun.com/icyfenix/dmla-sandbox:cpu --quiet', { timeout: 10000 })
296
- console.log(chalk.green(' ✅ TCR 连接正常'))
295
+ execSync('docker pull crpi-aani1ibpows293b8.cn-hangzhou.personal.cr.aliyuncs.com/fenixsoft/dmla-sandbox:cpu --quiet', { timeout: 10000 })
296
+ console.log(chalk.green(' ✅ ACR 连接正常'))
297
297
  } catch {
298
- console.log(chalk.yellow(' ⚠️ TCR 连接超时或受限'))
298
+ console.log(chalk.yellow(' ⚠️ ACR 连接超时或受限'))
299
299
  }
300
300
 
301
301
  console.log()
@@ -114,7 +114,75 @@ async function findServiceContainer() {
114
114
  }
115
115
 
116
116
  /**
117
- * 启动服务
117
+ * 查找服务器入口文件
118
+ */
119
+ function findServerPath() {
120
+ const serverPath = path.resolve(__dirname, '../../../local-server/src/index.js')
121
+ const standaloneServerPath = path.resolve(__dirname, '../server/index.js')
122
+
123
+ return fs.existsSync(serverPath) ? serverPath :
124
+ fs.existsSync(standaloneServerPath) ? standaloneServerPath : null
125
+ }
126
+
127
+ /**
128
+ * 同步启动服务(在当前进程运行,用于调试)
129
+ * @param {number} port - 服务端口
130
+ * @param {boolean} useGpu - 是否使用 GPU
131
+ */
132
+ export async function startServerSync(port, useGpu = false) {
133
+ // 检查端口
134
+ const portAvailable = await checkPortAvailable(port)
135
+ if (!portAvailable) {
136
+ console.log(chalk.red(`❌ 端口 ${port} 已被占用`))
137
+ console.log(chalk.yellow('💡 提示: 使用 --port 选项指定其他端口'))
138
+ return
139
+ }
140
+
141
+ // 检查镜像
142
+ const imageType = useGpu ? 'gpu' : 'cpu'
143
+ const imageExists = await checkImageExists(imageType)
144
+ if (!imageExists) {
145
+ console.log(chalk.red(`❌ 镜像 ${useGpu ? CONFIG.imageGpu : CONFIG.imageCpu} 不存在`))
146
+ console.log(chalk.yellow('💡 提示: 运行 dmla install 安装镜像'))
147
+ return
148
+ }
149
+
150
+ // 检查服务是否已运行
151
+ const alreadyRunning = await checkServiceRunning(port)
152
+ if (alreadyRunning) {
153
+ console.log(chalk.green(`✅ 服务已在端口 ${port} 运行`))
154
+ return
155
+ }
156
+
157
+ // 查找服务器入口
158
+ const actualServerPath = findServerPath()
159
+ if (!actualServerPath) {
160
+ console.log(chalk.red('❌ 找不到服务入口文件'))
161
+ console.log(chalk.yellow('💡 提示: 确保正确安装了 @icyfenix-dmla/cli'))
162
+ return
163
+ }
164
+
165
+ console.log(chalk.gray(' 同步模式启动...'))
166
+ console.log(chalk.gray(` 服务入口: ${actualServerPath}`))
167
+ console.log()
168
+
169
+ // 设置环境变量
170
+ process.env.PORT = port.toString()
171
+ process.env.USE_GPU = useGpu ? 'true' : 'false'
172
+ process.env.DMLA_SYNC_MODE = 'true' // 标记同步模式,让服务器在 import 时启动
173
+
174
+ // 动态 import 服务器模块并直接运行
175
+ // 服务器模块会在 import 时自动启动(因为入口点检测逻辑)
176
+ try {
177
+ await import(actualServerPath)
178
+ } catch (error) {
179
+ console.log(chalk.red(`❌ 服务启动失败: ${error.message}`))
180
+ console.log(chalk.gray(error.stack))
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 启动服务(异步模式,spawn 子进程)
118
186
  */
119
187
  export async function startServer(port, useGpu = false) {
120
188
  // 检查端口
@@ -145,14 +213,7 @@ export async function startServer(port, useGpu = false) {
145
213
  console.log(chalk.gray(' 正在启动...'))
146
214
 
147
215
  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
216
+ const actualServerPath = findServerPath()
156
217
 
157
218
  if (!actualServerPath) {
158
219
  console.log(chalk.red('❌ 找不到服务入口文件'))
@@ -200,7 +261,51 @@ export async function startServer(port, useGpu = false) {
200
261
  * 停止服务
201
262
  */
202
263
  export async function stopServer() {
203
- // 查找运行中的容器
264
+ // 首先尝试通过 API 停止服务
265
+ const port = CONFIG.defaultPort
266
+ const running = await checkServiceRunning(port)
267
+
268
+ if (running) {
269
+ try {
270
+ // 调用 shutdown API
271
+ await new Promise((resolve, reject) => {
272
+ const req = http.request({
273
+ hostname: 'localhost',
274
+ port: port,
275
+ path: '/api/shutdown',
276
+ method: 'POST',
277
+ timeout: 5000
278
+ }, (res) => {
279
+ if (res.statusCode === 200) {
280
+ console.log(chalk.green('✅ 服务已停止'))
281
+ resolve()
282
+ } else {
283
+ reject(new Error(`HTTP ${res.statusCode}`))
284
+ }
285
+ })
286
+ req.on('error', (e) => reject(e))
287
+ req.on('timeout', () => {
288
+ req.destroy()
289
+ reject(new Error('Timeout'))
290
+ })
291
+ req.end()
292
+ })
293
+
294
+ // 等待服务完全关闭
295
+ let attempts = 0
296
+ while (attempts < 10) {
297
+ const stillRunning = await checkServiceRunning(port)
298
+ if (!stillRunning) break
299
+ await new Promise(r => setTimeout(r, 200))
300
+ attempts++
301
+ }
302
+ return
303
+ } catch (error) {
304
+ console.log(chalk.yellow(`⚠️ 通过 API 停止失败: ${error.message}`))
305
+ }
306
+ }
307
+
308
+ // 尝试查找并停止 Docker 容器
204
309
  const container = await findServiceContainer()
205
310
 
206
311
  if (container) {
@@ -208,14 +313,15 @@ export async function stopServer() {
208
313
  const containerObj = docker.getContainer(container.Id)
209
314
  await containerObj.stop()
210
315
  await containerObj.remove()
211
- console.log(chalk.green('✅ 服务已停止'))
316
+ console.log(chalk.green('✅ 服务容器已停止'))
212
317
  } catch (error) {
213
- console.log(chalk.red(`❌ 停止失败: ${error.message}`))
318
+ console.log(chalk.red(`❌ 停止容器失败: ${error.message}`))
214
319
  }
320
+ } else if (!running) {
321
+ console.log(chalk.gray(' 服务未运行'))
215
322
  } else {
216
- // 尝试通过端口查找进程
217
- console.log(chalk.yellow('⚠️ 未找到运行中的服务容器'))
218
- console.log(chalk.gray(' 提示: 服务可能以非容器模式运行'))
323
+ console.log(chalk.yellow('⚠️ 无法停止服务'))
324
+ console.log(chalk.gray(' 提示: 手动终止端口 3001 上的进程'))
219
325
  }
220
326
  }
221
327
 
package/src/index.js CHANGED
@@ -4,8 +4,9 @@
4
4
  */
5
5
  import { program } from 'commander'
6
6
  import chalk from 'chalk'
7
- import { startServer, stopServer, getStatus } from './commands/server.js'
8
- import { installImages, updateAll, runDoctor } from './commands/manage.js'
7
+ import { startServer, startServerSync, stopServer, getStatus } from './commands/server.js'
8
+ import { updateAll, runDoctor } from './commands/manage.js'
9
+ import { runInstallTUI } from '@icyfenix-dmla/install'
9
10
 
10
11
  const VERSION = '0.0.0' // 将在发布时由 workflow 更新
11
12
 
@@ -22,13 +23,24 @@ program
22
23
  .description('启动沙箱服务')
23
24
  .option('-p, --port <number>', '服务端口', '3001')
24
25
  .option('--gpu', '使用 GPU 镜像')
26
+ .option('--sync', '同步模式:在当前进程运行,日志直接输出(用于调试)')
25
27
  .action(async (options) => {
26
28
  const port = parseInt(options.port, 10)
27
29
  const useGpu = options.gpu
30
+ const sync = options.sync
31
+
28
32
  console.log(chalk.blue('🚀 启动 DMLA 沙箱服务...'))
29
33
  console.log(chalk.gray(` 端口: ${port}`))
30
34
  console.log(chalk.gray(` 镜像: ${useGpu ? 'GPU' : 'CPU'}`))
31
- await startServer(port, useGpu)
35
+ if (sync) {
36
+ console.log(chalk.yellow(` 模式: 同步(调试模式)`))
37
+ }
38
+
39
+ if (sync) {
40
+ await startServerSync(port, useGpu)
41
+ } else {
42
+ await startServer(port, useGpu)
43
+ }
32
44
  })
33
45
 
34
46
  // ─────────────────────────────────────────────────────────────
@@ -58,24 +70,9 @@ program
58
70
  // ─────────────────────────────────────────────────────────────
59
71
  program
60
72
  .command('install')
61
- .description('安装 Docker 镜像')
62
- .option('--cpu', '仅安装 CPU 版本')
63
- .option('--gpu', '仅安装 GPU 版本')
64
- .option('--all', '安装所有镜像(默认)')
65
- .option('-r, --registry <type>', '镜像仓库 (dockerhub/tcr)', 'dockerhub')
66
- .action(async (options) => {
67
- const registry = options.registry
68
- let types = []
69
-
70
- if (options.cpu) types.push('cpu')
71
- if (options.gpu) types.push('gpu')
72
- if (types.length === 0 || options.all) types = ['cpu', 'gpu']
73
-
74
- console.log(chalk.blue('📦 安装 DMLA Docker 镜像...'))
75
- console.log(chalk.gray(` 仓库: ${registry}`))
76
- console.log(chalk.gray(` 类型: ${types.join(', ')}`))
77
-
78
- await installImages(types, registry)
73
+ .description('启动安装向导')
74
+ .action(async () => {
75
+ await runInstallTUI()
79
76
  })
80
77
 
81
78
  // ─────────────────────────────────────────────────────────────
@@ -84,7 +81,7 @@ program
84
81
  program
85
82
  .command('update')
86
83
  .description('更新 npm 包和 Docker 镜像')
87
- .option('-r, --registry <type>', '镜像仓库 (dockerhub/tcr)', 'dockerhub')
84
+ .option('-r, --registry <type>', '镜像仓库 (dockerhub/acr)', 'dockerhub')
88
85
  .action(async (options) => {
89
86
  console.log(chalk.blue('🔄 更新 DMLA...'))
90
87
  await updateAll(options.registry)
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Design Machine Learning Applications 本地服务
3
+ * 提供 Python 代码沙箱执行 API
4
+ */
5
+ import express from 'express'
6
+ import cors from 'cors'
7
+ import { fileURLToPath } from 'url'
8
+ import { resolve } from 'path'
9
+ import sandboxRouter from './routes/sandbox.js'
10
+
11
+ export const app = express()
12
+ const PORT = process.env.PORT || 3001
13
+
14
+ // 中间件
15
+ app.use(cors())
16
+ app.use(express.json())
17
+
18
+ // 健康检查
19
+ app.get('/api/health', (req, res) => {
20
+ res.json({ status: 'ok', timestamp: new Date().toISOString() })
21
+ })
22
+
23
+ // 停止服务(用于 CLI stop 命令)
24
+ app.post('/api/shutdown', (req, res) => {
25
+ res.json({ status: 'shutting_down', timestamp: new Date().toISOString() })
26
+ console.log('🛑 收到停止请求,服务即将关闭...')
27
+ // 延迟关闭,确保响应发送完成
28
+ setTimeout(() => {
29
+ process.exit(0)
30
+ }, 100)
31
+ })
32
+
33
+ // 沙箱 API
34
+ app.use('/api/sandbox', sandboxRouter)
35
+
36
+ // 错误处理
37
+ app.use((err, req, res, next) => {
38
+ console.error('Error:', err)
39
+ res.status(500).json({
40
+ success: false,
41
+ error: err.message || 'Internal Server Error'
42
+ })
43
+ })
44
+
45
+ // 启动服务器
46
+ // 条件1: 直接运行(入口点匹配)
47
+ // 条件2: 同步模式(DMLA_SYNC_MODE 环境变量)
48
+ const __filename = fileURLToPath(import.meta.url)
49
+ const entryPoint = resolve(process.argv[1] || '')
50
+ const shouldStart = __filename === entryPoint || process.env.DMLA_SYNC_MODE === 'true'
51
+
52
+ if (shouldStart) {
53
+ app.listen(PORT, () => {
54
+ console.log(`🚀 DMLA 本地服务已启动`)
55
+ console.log(` API: http://localhost:${PORT}`)
56
+ console.log(` 健康检查: http://localhost:${PORT}/api/health`)
57
+ })
58
+ }
59
+
60
+ export default app
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 沙箱 API 路由
3
+ */
4
+ import { Router } from 'express'
5
+ import { runPythonCode, checkImageExists, checkGPUAvailable } from '../sandbox.js'
6
+
7
+ const router = Router()
8
+
9
+ /**
10
+ * 健康检查
11
+ */
12
+ router.get('/health', async (req, res) => {
13
+ try {
14
+ const imageCpuExists = await checkImageExists(false)
15
+ const imageGpuExists = await checkImageExists(true)
16
+ const gpuAvailable = await checkGPUAvailable()
17
+
18
+ res.json({
19
+ status: 'ok',
20
+ images: {
21
+ cpu: imageCpuExists,
22
+ gpu: imageGpuExists
23
+ },
24
+ gpu: gpuAvailable
25
+ })
26
+ } catch (error) {
27
+ res.status(500).json({
28
+ status: 'error',
29
+ error: error.message
30
+ })
31
+ }
32
+ })
33
+
34
+ /**
35
+ * 执行代码
36
+ * POST /api/sandbox/run
37
+ * Body: { code: string, useGpu?: boolean }
38
+ */
39
+ router.post('/run', async (req, res) => {
40
+ const { code, useGpu = false } = req.body
41
+
42
+ // 验证请求
43
+ if (!code || typeof code !== 'string') {
44
+ return res.status(400).json({
45
+ success: false,
46
+ error: 'Missing or invalid code parameter'
47
+ })
48
+ }
49
+
50
+ // 代码长度限制 (约 100KB)
51
+ if (code.length > 100000) {
52
+ return res.status(400).json({
53
+ success: false,
54
+ error: 'Code too long (max 100KB)'
55
+ })
56
+ }
57
+
58
+ try {
59
+ // 检查镜像是否存在
60
+ const imageExists = await checkImageExists(useGpu)
61
+
62
+ if (!imageExists) {
63
+ return res.status(503).json({
64
+ success: false,
65
+ error: useGpu
66
+ ? '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'
68
+ })
69
+ }
70
+
71
+ // 如果请求 GPU,检查 GPU 是否可用
72
+ if (useGpu) {
73
+ const gpuAvailable = await checkGPUAvailable()
74
+ if (!gpuAvailable) {
75
+ return res.status(503).json({
76
+ 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 进行环境诊断'
78
+ })
79
+ }
80
+ }
81
+
82
+ // 执行代码
83
+ const result = await runPythonCode(code, useGpu)
84
+
85
+ res.json(result)
86
+
87
+ } catch (error) {
88
+ console.error('Sandbox error:', error)
89
+ res.status(500).json({
90
+ success: false,
91
+ error: error.message || 'Internal sandbox error'
92
+ })
93
+ }
94
+ })
95
+
96
+ /**
97
+ * GPU 状态检查
98
+ */
99
+ router.get('/gpu', async (req, res) => {
100
+ try {
101
+ const gpuAvailable = await checkGPUAvailable()
102
+
103
+ res.json({
104
+ available: gpuAvailable,
105
+ message: gpuAvailable ? 'GPU is available' : 'No GPU detected'
106
+ })
107
+ } catch (error) {
108
+ res.status(500).json({
109
+ available: false,
110
+ error: error.message
111
+ })
112
+ }
113
+ })
114
+
115
+ export default router
@@ -0,0 +1,351 @@
1
+ /**
2
+ * 沙箱管理模块
3
+ * 负责创建和管理 Docker 容器执行 Python 代码
4
+ */
5
+ import Docker from 'dockerode'
6
+ import path from 'path'
7
+ import { fileURLToPath } from 'url'
8
+ import fs from 'fs'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = path.dirname(__filename)
12
+
13
+ // 检测运行模式并计算正确的路径
14
+ // 开发模式: 从 local-server/src 运行,项目根目录在上两级
15
+ // 独立模式: 从 packages/cli/src/server 运行,无 shared_modules 目录
16
+ function detectProjectRoot() {
17
+ // 尝试向上两级查找 local-server 目录(开发模式)
18
+ const candidateRoot = path.resolve(__dirname, '..', '..')
19
+ const localServerPath = path.join(candidateRoot, 'local-server')
20
+ if (fs.existsSync(localServerPath)) {
21
+ return candidateRoot
22
+ }
23
+
24
+ // 尝试向上三级查找(独立模式下的项目根目录)
25
+ const standaloneRoot = path.resolve(__dirname, '..', '..', '..')
26
+ const standaloneLocalServer = path.join(standaloneRoot, 'local-server')
27
+ if (fs.existsSync(standaloneLocalServer)) {
28
+ return standaloneRoot
29
+ }
30
+
31
+ // 独立安装模式,无项目根目录
32
+ return null
33
+ }
34
+
35
+ const PROJECT_ROOT = detectProjectRoot()
36
+
37
+ // 共享模块目录(仅开发模式可用)
38
+ const DEFAULT_SHARED_MODULES_PATH = PROJECT_ROOT
39
+ ? path.join(PROJECT_ROOT, 'local-server', 'shared_modules')
40
+ : null
41
+
42
+ const docker = new Docker()
43
+
44
+ // 沙箱配置
45
+ const SANDBOX_CONFIG = {
46
+ imageCpu: 'dmla-sandbox:cpu',
47
+ imageGpu: 'dmla-sandbox:gpu',
48
+ timeout: 60000, // 60 秒超时
49
+ memory: 4 * 1024 * 1024 * 1024 // 4GB 内存
50
+ }
51
+
52
+ /**
53
+ * 获取共享模块路径
54
+ */
55
+ function getSharedModulesPath() {
56
+ // 优先使用环境变量指定的路径
57
+ if (process.env.SHARED_MODULES_PATH) {
58
+ return process.env.SHARED_MODULES_PATH
59
+ }
60
+ // 开发模式下的默认路径
61
+ return DEFAULT_SHARED_MODULES_PATH
62
+ }
63
+
64
+ /**
65
+ * 检查是否启用 Volume Mount
66
+ */
67
+ function shouldMountSharedModules() {
68
+ return process.env.MOUNT_SHARED_MODULES !== 'false'
69
+ }
70
+
71
+ /**
72
+ * 检查 GPU 是否可用
73
+ * 通过运行 nvidia-smi 命令检测 GPU 状态
74
+ */
75
+ export async function checkGPUAvailable() {
76
+ let container = null
77
+
78
+ try {
79
+ // 创建容器运行 nvidia-smi 命令
80
+ container = await docker.createContainer({
81
+ Image: 'nvidia/cuda:11.8-base',
82
+ Cmd: ['nvidia-smi', '-L'],
83
+ HostConfig: {
84
+ DeviceRequests: [{
85
+ Driver: 'nvidia',
86
+ Count: -1, // 使用所有 GPU
87
+ Capabilities: [['gpu']]
88
+ }]
89
+ }
90
+ })
91
+
92
+ // 启动容器
93
+ await container.start()
94
+
95
+ // 等待执行完成
96
+ await container.wait()
97
+
98
+ // 获取输出日志
99
+ const logs = await container.logs({
100
+ stdout: true,
101
+ stderr: true
102
+ })
103
+
104
+ // 解析输出
105
+ const output = parseDockerLogs(logs)
106
+
107
+ // 检查输出是否包含 GPU 信息
108
+ return output.includes('GPU')
109
+ } catch {
110
+ // GPU 不可用或 Docker/nvidia-smi 执行失败
111
+ return false
112
+ } finally {
113
+ // 清理容器
114
+ if (container) {
115
+ try {
116
+ await container.remove({ force: true })
117
+ } catch {
118
+ // 忽略清理错误
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 执行 Python 代码
126
+ * 使用 IPython Kernel 执行代码,支持富输出(图片、文本、错误等)
127
+ * @param {string} code - Python 代码
128
+ * @param {boolean} useGpu - 是否使用 GPU
129
+ * @returns {Promise<{success: boolean, outputs: Array, executionTime: number, gpuUsed: boolean}>}
130
+ */
131
+ export async function runPythonCode(code, useGpu = false) {
132
+ const startTime = Date.now()
133
+
134
+ // 选择镜像
135
+ const image = useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu
136
+
137
+ // 创建容器配置 - 使用 kernel_runner.py 执行代码
138
+ const containerConfig = {
139
+ Image: image,
140
+ Cmd: ['python3', '/workspace/kernel_runner.py', '--code', code, '--timeout', String(Math.floor(SANDBOX_CONFIG.timeout / 1000))],
141
+ HostConfig: {
142
+ Memory: SANDBOX_CONFIG.memory,
143
+ AutoRemove: false // 手动移除以获取日志
144
+ },
145
+ Env: [
146
+ 'PYTHONUNBUFFERED=1'
147
+ // matplotlib 使用 IPython Kernel 的 inline 后端,自动发送 display_data
148
+ ]
149
+ }
150
+
151
+ // Volume Mount 配置 - 挂载共享模块
152
+ const useMount = shouldMountSharedModules()
153
+ const sharedModulesPath = getSharedModulesPath()
154
+
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)')
170
+ }
171
+
172
+ // GPU 配置
173
+ if (useGpu) {
174
+ containerConfig.HostConfig.DeviceRequests = [{
175
+ Driver: 'nvidia',
176
+ Count: -1, // 使用所有 GPU
177
+ Capabilities: [['gpu']]
178
+ }]
179
+ }
180
+
181
+ let container = null
182
+ let timeoutId = null
183
+
184
+ try {
185
+ // 创建容器
186
+ container = await docker.createContainer(containerConfig)
187
+
188
+ // 设置超时
189
+ const timeoutPromise = new Promise((_, reject) => {
190
+ timeoutId = setTimeout(() => {
191
+ reject(new Error('Execution timeout'))
192
+ }, SANDBOX_CONFIG.timeout + 10000) // 额外 10 秒用于清理
193
+ })
194
+
195
+ // 启动容器
196
+ await container.start()
197
+
198
+ // 等待执行完成
199
+ const waitPromise = container.wait()
200
+
201
+ // 竞速: 超时 vs 正常完成
202
+ const result = await Promise.race([waitPromise, timeoutPromise])
203
+
204
+ // 清除超时
205
+ if (timeoutId) clearTimeout(timeoutId)
206
+
207
+ // 获取输出
208
+ const logs = await container.logs({
209
+ stdout: true,
210
+ stderr: true
211
+ })
212
+
213
+ // 解析输出
214
+ const rawOutput = parseDockerLogs(logs)
215
+
216
+ // 解析 JSON 输出
217
+ let parsedResult
218
+ try {
219
+ parsedResult = JSON.parse(rawOutput)
220
+ } catch (parseError) {
221
+ // 如果 JSON 解析失败,返回原始输出作为错误
222
+ const executionTime = (Date.now() - startTime) / 1000
223
+ return {
224
+ success: false,
225
+ outputs: [{
226
+ type: 'error',
227
+ ename: 'OutputParseError',
228
+ evalue: 'Failed to parse kernel output',
229
+ traceback: [rawOutput]
230
+ }],
231
+ executionTime,
232
+ gpuUsed: useGpu
233
+ }
234
+ }
235
+
236
+ return {
237
+ success: parsedResult.success,
238
+ outputs: parsedResult.outputs || [],
239
+ executionTime: parsedResult.executionTime || (Date.now() - startTime) / 1000,
240
+ gpuUsed: useGpu
241
+ }
242
+
243
+ } catch (error) {
244
+ // 清除超时
245
+ if (timeoutId) clearTimeout(timeoutId)
246
+
247
+ const executionTime = (Date.now() - startTime) / 1000
248
+
249
+ return {
250
+ success: false,
251
+ outputs: [{
252
+ type: 'error',
253
+ ename: error.name || 'ExecutionError',
254
+ evalue: error.message || 'Unknown error',
255
+ traceback: [error.message || 'Unknown error']
256
+ }],
257
+ executionTime,
258
+ gpuUsed: useGpu
259
+ }
260
+
261
+ } finally {
262
+ // 清理容器
263
+ if (container) {
264
+ try {
265
+ await container.remove({ force: true })
266
+ } catch (e) {
267
+ console.warn('Failed to remove container:', e.message)
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * 解析 Docker 日志输出
275
+ * Docker 日志格式: [8字节头][数据]
276
+ */
277
+ function parseDockerLogs(logs) {
278
+ if (!logs || logs.length === 0) return ''
279
+
280
+ // 如果是 Buffer
281
+ if (Buffer.isBuffer(logs)) {
282
+ let output = ''
283
+ let offset = 0
284
+
285
+ while (offset < logs.length) {
286
+ // 跳过 8 字节头
287
+ if (offset + 8 > logs.length) break
288
+
289
+ const streamType = logs[offset]
290
+ const length = logs.readUInt32BE(offset + 4)
291
+
292
+ offset += 8
293
+
294
+ if (offset + length > logs.length) break
295
+
296
+ const chunk = logs.slice(offset, offset + length).toString('utf8')
297
+ output += chunk
298
+ offset += length
299
+ }
300
+
301
+ return output
302
+ }
303
+
304
+ // 如果是字符串
305
+ return logs.toString()
306
+ }
307
+
308
+ /**
309
+ * 检查沙箱镜像是否存在
310
+ */
311
+ export async function checkImageExists(useGpu = false) {
312
+ const image = useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu
313
+
314
+ try {
315
+ await docker.getImage(image).inspect()
316
+ return true
317
+ } catch {
318
+ return false
319
+ }
320
+ }
321
+
322
+ /**
323
+ * 拉取沙箱镜像
324
+ */
325
+ export async function pullImage(useGpu = false) {
326
+ const image = useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu
327
+
328
+ return new Promise((resolve, reject) => {
329
+ docker.pull(image, (err, stream) => {
330
+ if (err) {
331
+ reject(err)
332
+ return
333
+ }
334
+
335
+ docker.modem.followProgress(stream, (err, output) => {
336
+ if (err) {
337
+ reject(err)
338
+ } else {
339
+ resolve(output)
340
+ }
341
+ })
342
+ })
343
+ })
344
+ }
345
+
346
+ export default {
347
+ runPythonCode,
348
+ checkGPUAvailable,
349
+ checkImageExists,
350
+ pullImage
351
+ }
Binary file
package/tests/cli.test.js DELETED
@@ -1,34 +0,0 @@
1
- /**
2
- * CLI 命令单元测试
3
- */
4
- import { describe, it, expect, beforeAll } from '@jest/globals'
5
- import { fileURLToPath } from 'url'
6
- import path from 'path'
7
- import fs from 'fs'
8
-
9
- // ESM 中获取 __dirname
10
- const __filename = fileURLToPath(import.meta.url)
11
- const __dirname = path.dirname(__filename)
12
-
13
- // 基础测试:确保模块可导入
14
- describe('CLI Module', () => {
15
- beforeAll(() => {
16
- // 设置测试环境
17
- })
18
-
19
- it('should have correct package.json', async () => {
20
- const pkgPath = path.resolve(__dirname, '../package.json')
21
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
22
-
23
- expect(pkg.name).toBe('@icyfenix-dmla/cli')
24
- expect(pkg.bin).toBeDefined()
25
- expect(pkg.bin.dmla).toBe('./bin/dmla.js')
26
- })
27
-
28
- it('should have commander dependency', async () => {
29
- const pkgPath = path.resolve(__dirname, '../package.json')
30
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
31
-
32
- expect(pkg.dependencies.commander).toBeDefined()
33
- })
34
- })