mobox-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1406 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Framework Detector - 框架检测器
4
+
5
+ 自动识别项目框架类型、构建命令、启动命令和 Node.js 版本
6
+ 支持 MCP 特性检测和 Hybrid 混合项目识别
7
+
8
+ 版本: v1.1.0
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+
19
+ # ============= MCP 依赖配置 =============
20
+ MCP_DEPENDENCIES = {
21
+ 'python': ['mcp', 'fastmcp'],
22
+ 'nodejs': ['@modelcontextprotocol/sdk'],
23
+ }
24
+
25
+ # MCP Transport 模式匹配
26
+ MCP_TRANSPORT_PATTERNS = {
27
+ 'python': {
28
+ 'sse': [
29
+ r'transport\s*=\s*["\']sse["\']',
30
+ r'transport\s*=\s*["\']http["\']',
31
+ r'stateless_http\s*=\s*True',
32
+ ],
33
+ 'stdio': [r'transport\s*=\s*["\']stdio["\']'],
34
+ },
35
+ 'nodejs': {
36
+ 'sse': [r'SSEServerTransport', r'StreamableHTTPServerTransport'],
37
+ 'stdio': [r'StdioServerTransport'],
38
+ },
39
+ }
40
+
41
+ # Hybrid 检测时忽略的目录
42
+ HYBRID_IGNORE_DIRS = [
43
+ 'node_modules', 'venv', '.venv', 'env', '.env',
44
+ '__pycache__', '.git', 'dist', 'build', '.next',
45
+ '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache',
46
+ ]
47
+
48
+
49
+ # ============= Python 支持配置 =============
50
+ # 支持的 Python 版本
51
+ SUPPORTED_PYTHON_VERSIONS = ['3.9', '3.10', '3.11', '3.12', '3.13']
52
+ DEFAULT_PYTHON_VERSION = '3.11'
53
+
54
+ # 需要系统依赖的库黑名单
55
+ SYSTEM_DEPENDENCY_LIBRARIES = {
56
+ 'opencv-python': '需要 libGL, libgthread 等系统库 (建议用 opencv-python-headless)',
57
+ 'opencv-contrib-python': '需要 libGL, libgthread 等系统库',
58
+ 'mysqlclient': '需要 libmysqlclient-dev (建议用 PyMySQL)',
59
+ 'psycopg2': '需要 libpq-dev (建议用 psycopg2-binary)',
60
+ 'lxml': '需要 libxml2-dev, libxslt1-dev',
61
+ 'pyodbc': '需要 unixodbc-dev',
62
+ 'pycurl': '需要 libcurl4-openssl-dev',
63
+ 'python-ldap': '需要 libldap2-dev, libsasl2-dev',
64
+ }
65
+
66
+
67
+ # ============= 框架检测规则 =============
68
+ # 框架检测规则(按优先级排序)
69
+ DETECTION_RULES = {
70
+ 'nextjs': {
71
+ 'dependencies': ['next'],
72
+ 'scripts_patterns': ['next build', 'next start'],
73
+ 'files': ['next.config.js', 'next.config.mjs', 'next.config.ts'],
74
+ 'dirs': ['pages', 'app'],
75
+ 'default_port': 3000,
76
+ 'build_command': 'npm run build',
77
+ 'start_command': 'npm start',
78
+ },
79
+ 'nuxt': {
80
+ 'dependencies': ['nuxt'],
81
+ 'scripts_patterns': ['nuxt build', 'nuxt dev'],
82
+ 'files': ['nuxt.config.js', 'nuxt.config.ts'],
83
+ 'dirs': [],
84
+ 'default_port': 3000,
85
+ 'build_command': 'npm run build',
86
+ 'start_command': 'npm start',
87
+ },
88
+ 'vite-react': {
89
+ 'dependencies': ['vite', 'react'],
90
+ 'scripts_patterns': ['vite build'],
91
+ 'files': ['vite.config.js', 'vite.config.ts'],
92
+ 'dirs': [],
93
+ 'default_port': 4173,
94
+ 'build_command': 'npm run build',
95
+ 'start_command': 'npm run preview',
96
+ },
97
+ 'vite-vue': {
98
+ 'dependencies': ['vite', 'vue'],
99
+ 'scripts_patterns': ['vite build'],
100
+ 'files': ['vite.config.js', 'vite.config.ts'],
101
+ 'dirs': [],
102
+ 'default_port': 4173,
103
+ 'build_command': 'npm run build',
104
+ 'start_command': 'npm run preview',
105
+ },
106
+ 'remix': {
107
+ 'dependencies': ['@remix-run/node', '@remix-run/react'],
108
+ 'scripts_patterns': ['remix build'],
109
+ 'files': ['remix.config.js'],
110
+ 'dirs': ['app'],
111
+ 'default_port': 3000,
112
+ 'build_command': 'npm run build',
113
+ 'start_command': 'npm start',
114
+ },
115
+ 'astro': {
116
+ 'dependencies': ['astro'],
117
+ 'scripts_patterns': ['astro build'],
118
+ 'files': ['astro.config.mjs', 'astro.config.js'],
119
+ 'dirs': [],
120
+ 'default_port': 3000,
121
+ 'build_command': 'npm run build',
122
+ 'start_command': 'npm run preview',
123
+ },
124
+ 'sveltekit': {
125
+ 'dependencies': ['@sveltejs/kit'],
126
+ 'scripts_patterns': ['vite build'],
127
+ 'files': ['svelte.config.js'],
128
+ 'dirs': [],
129
+ 'default_port': 5173,
130
+ 'build_command': 'npm run build',
131
+ 'start_command': 'npm run preview',
132
+ },
133
+ 'express': {
134
+ 'dependencies': ['express'],
135
+ 'scripts_patterns': ['node server', 'node index', 'node app'],
136
+ 'files': ['server.js', 'app.js', 'index.js'],
137
+ 'dirs': [],
138
+ 'default_port': 3000,
139
+ 'build_command': 'npm run build',
140
+ 'start_command': 'npm start',
141
+ },
142
+
143
+ # ============= Python 框架 =============
144
+ 'flask': {
145
+ 'dependencies': ['flask'],
146
+ 'files': ['app.py', 'application.py', 'wsgi.py'],
147
+ 'default_port': 5000,
148
+ 'build_command': None,
149
+ 'language': 'python',
150
+ },
151
+ 'fastapi': {
152
+ 'dependencies': ['fastapi'],
153
+ 'files': ['main.py', 'app.py', 'api.py'],
154
+ 'default_port': 8000,
155
+ 'build_command': None,
156
+ 'language': 'python',
157
+ },
158
+ 'streamlit': {
159
+ 'dependencies': ['streamlit'],
160
+ 'files': ['app.py', 'streamlit_app.py'],
161
+ 'default_port': 8501,
162
+ 'build_command': None,
163
+ 'language': 'python',
164
+ },
165
+ 'mcp': {
166
+ 'dependencies': ['mcp', 'fastmcp'],
167
+ 'files': ['server.py', 'mcp_server.py', 'main.py'],
168
+ 'default_port': 9192,
169
+ 'build_command': None,
170
+ 'language': 'python',
171
+ },
172
+ }
173
+
174
+
175
+ class FrameworkDetector:
176
+ def __init__(self, project_path: str):
177
+ self.project_path = Path(project_path).resolve()
178
+ self.package_json_path = self.project_path / 'package.json'
179
+ self.package_json = None
180
+
181
+ def detect(self) -> Dict:
182
+ """执行框架检测(支持 hybrid 模式)"""
183
+
184
+ # 0. 首先检测是否为 hybrid 项目
185
+ hybrid_result = self._detect_hybrid()
186
+ if hybrid_result:
187
+ return hybrid_result
188
+
189
+ # 1. 优先检查 requirements.txt (Python 项目)
190
+ req_file = self.project_path / 'requirements.txt'
191
+ if req_file.exists():
192
+ return self._detect_python_framework()
193
+
194
+ # 2. 检查 package.json (Node.js 项目)
195
+ if not self.package_json_path.exists():
196
+ return self._error_result(
197
+ "No package.json or requirements.txt found. "
198
+ "This doesn't appear to be a Node.js or Python project."
199
+ )
200
+
201
+ # 3. 执行 Node.js 检测 (保持原有逻辑)
202
+ try:
203
+ with open(self.package_json_path, 'r', encoding='utf-8') as f:
204
+ self.package_json = json.load(f)
205
+ except Exception as e:
206
+ return self._error_result(f"Failed to read package.json: {e}")
207
+
208
+ # 按优先级检测 Node.js 框架
209
+ # 1. 分析 dependencies
210
+ result = self._detect_by_dependencies()
211
+ if result:
212
+ return self._success_result(result)
213
+
214
+ # 2. 分析 scripts
215
+ result = self._detect_by_scripts()
216
+ if result:
217
+ return self._success_result(result)
218
+
219
+ # 3. 分析项目结构
220
+ result = self._detect_by_structure()
221
+ if result:
222
+ return self._success_result(result)
223
+
224
+ # 无法识别
225
+ return self._unknown_result()
226
+
227
+ # ============= Hybrid 检测方法 =============
228
+
229
+ def _detect_hybrid(self) -> Optional[Dict]:
230
+ """检测是否为 hybrid 混合项目"""
231
+ root_languages = self._detect_languages_in_dir(self.project_path)
232
+
233
+ # 扫描一级子目录
234
+ sub_components = []
235
+ try:
236
+ for item in self.project_path.iterdir():
237
+ if not item.is_dir():
238
+ continue
239
+ if item.name in HYBRID_IGNORE_DIRS:
240
+ continue
241
+ if item.name.startswith('.'):
242
+ continue
243
+
244
+ sub_languages = self._detect_languages_in_dir(item)
245
+ if sub_languages:
246
+ sub_components.append({
247
+ 'path': f'./{item.name}',
248
+ 'languages': sub_languages,
249
+ })
250
+ except PermissionError:
251
+ pass
252
+
253
+ # 判断是否为 hybrid
254
+ all_languages = set(root_languages)
255
+ for comp in sub_components:
256
+ all_languages.update(comp['languages'])
257
+
258
+ if len(all_languages) < 2:
259
+ return None # 不是 hybrid 项目
260
+
261
+ # 构建 hybrid 结果
262
+ return self._build_hybrid_result(root_languages, sub_components)
263
+
264
+ def _detect_languages_in_dir(self, dir_path: Path) -> List[str]:
265
+ """检测目录中的语言类型"""
266
+ languages = []
267
+
268
+ if (dir_path / 'requirements.txt').exists():
269
+ languages.append('python')
270
+ if (dir_path / 'package.json').exists():
271
+ languages.append('nodejs')
272
+
273
+ return languages
274
+
275
+ def _build_hybrid_result(self, root_languages: List[str], sub_components: List[Dict]) -> Dict:
276
+ """构建 hybrid 检测结果"""
277
+ components = []
278
+ all_requires = set()
279
+ features = []
280
+
281
+ # 处理根目录组件
282
+ if root_languages:
283
+ for lang in root_languages:
284
+ comp_result = self._detect_component('.', lang)
285
+ if comp_result:
286
+ comp_result['primary'] = True # 根目录组件默认为 primary
287
+ components.append(comp_result)
288
+ all_requires.add(lang)
289
+
290
+ # 检查 MCP 特性
291
+ if comp_result.get('mcp'):
292
+ if 'mcp' not in features:
293
+ features.append('mcp')
294
+
295
+ # 处理子目录组件
296
+ for sub in sub_components:
297
+ for lang in sub['languages']:
298
+ comp_result = self._detect_component(sub['path'], lang)
299
+ if comp_result:
300
+ if not components: # 如果根目录没有组件,第一个子目录组件为 primary
301
+ comp_result['primary'] = True
302
+ components.append(comp_result)
303
+ all_requires.add(lang)
304
+
305
+ # 检查 MCP 特性
306
+ if comp_result.get('mcp'):
307
+ if 'mcp' not in features:
308
+ features.append('mcp')
309
+
310
+ # 检测启动脚本
311
+ start_command = self._detect_start_script()
312
+
313
+ result = {
314
+ 'success': True,
315
+ 'framework': 'hybrid',
316
+ 'components': components,
317
+ 'runtime': {
318
+ 'command': start_command,
319
+ 'requires': sorted(list(all_requires)),
320
+ },
321
+ }
322
+
323
+ if features:
324
+ result['features'] = features
325
+
326
+ return result
327
+
328
+ def _detect_component(self, path: str, language: str) -> Optional[Dict]:
329
+ """检测单个组件的框架信息"""
330
+ if path == '.':
331
+ comp_path = self.project_path
332
+ else:
333
+ comp_path = self.project_path / path.lstrip('./')
334
+
335
+ if language == 'python':
336
+ return self._detect_python_component(comp_path, path)
337
+ elif language == 'nodejs':
338
+ return self._detect_nodejs_component(comp_path, path)
339
+
340
+ return None
341
+
342
+ def _detect_python_component(self, comp_path: Path, rel_path: str) -> Optional[Dict]:
343
+ """检测 Python 组件"""
344
+ req_file = comp_path / 'requirements.txt'
345
+ if not req_file.exists():
346
+ return None
347
+
348
+ try:
349
+ req_content = req_file.read_text(encoding='utf-8').lower()
350
+ except Exception:
351
+ return None
352
+
353
+ # 检测框架
354
+ detected_framework = None
355
+ for framework, rules in DETECTION_RULES.items():
356
+ if rules.get('language') != 'python':
357
+ continue
358
+
359
+ for dep in rules['dependencies']:
360
+ if dep.lower() in req_content:
361
+ # 检查入口文件
362
+ for file in rules.get('files', []):
363
+ if (comp_path / file).exists():
364
+ detected_framework = framework
365
+ break
366
+ if detected_framework:
367
+ break
368
+ if detected_framework:
369
+ break
370
+
371
+ if not detected_framework:
372
+ detected_framework = 'python' # 通用 Python
373
+
374
+ port = DETECTION_RULES.get(detected_framework, {}).get('default_port', 8000)
375
+
376
+ result = {
377
+ 'path': rel_path,
378
+ 'language': 'python',
379
+ 'framework': detected_framework,
380
+ 'port': port,
381
+ }
382
+
383
+ # 检测 MCP 特性
384
+ mcp_info = self._detect_mcp_feature(comp_path, 'python', req_content)
385
+ if mcp_info:
386
+ result['mcp'] = mcp_info
387
+ if mcp_info.get('port'):
388
+ result['port'] = mcp_info['port']
389
+
390
+ return result
391
+
392
+ def _detect_nodejs_component(self, comp_path: Path, rel_path: str) -> Optional[Dict]:
393
+ """检测 Node.js 组件"""
394
+ pkg_file = comp_path / 'package.json'
395
+ if not pkg_file.exists():
396
+ return None
397
+
398
+ try:
399
+ with open(pkg_file, 'r', encoding='utf-8') as f:
400
+ pkg_json = json.load(f)
401
+ except Exception:
402
+ return None
403
+
404
+ dependencies = {
405
+ **pkg_json.get('dependencies', {}),
406
+ **pkg_json.get('devDependencies', {}),
407
+ }
408
+
409
+ # 检测框架
410
+ detected_framework = None
411
+ for framework, rules in DETECTION_RULES.items():
412
+ if rules.get('language') == 'python':
413
+ continue
414
+ if 'dependencies' not in rules:
415
+ continue
416
+
417
+ if all(dep in dependencies for dep in rules['dependencies']):
418
+ detected_framework = framework
419
+ break
420
+
421
+ if not detected_framework:
422
+ detected_framework = 'nodejs' # 通用 Node.js
423
+
424
+ port = DETECTION_RULES.get(detected_framework, {}).get('default_port', 3000)
425
+
426
+ result = {
427
+ 'path': rel_path,
428
+ 'language': 'nodejs',
429
+ 'framework': detected_framework,
430
+ 'port': port,
431
+ }
432
+
433
+ # 检测 MCP 特性
434
+ mcp_info = self._detect_mcp_feature(comp_path, 'nodejs', json.dumps(dependencies))
435
+ if mcp_info:
436
+ result['mcp'] = mcp_info
437
+ if mcp_info.get('port'):
438
+ result['port'] = mcp_info['port']
439
+
440
+ return result
441
+
442
+ def _detect_mcp_feature(self, comp_path: Path, language: str, deps_content: str) -> Optional[Dict]:
443
+ """检测 MCP 特性"""
444
+ # 检查是否有 MCP 依赖
445
+ has_mcp = False
446
+ for dep in MCP_DEPENDENCIES.get(language, []):
447
+ if dep.lower() in deps_content.lower():
448
+ has_mcp = True
449
+ break
450
+
451
+ if not has_mcp:
452
+ return None
453
+
454
+ # 检测 transport 模式
455
+ transport = self._detect_mcp_transport(comp_path, language)
456
+ port = self._detect_mcp_port(comp_path, language)
457
+
458
+ mcp_info = {
459
+ 'transport': transport,
460
+ }
461
+
462
+ if transport == 'sse' and port:
463
+ mcp_info['port'] = port
464
+ elif transport == 'stdio':
465
+ mcp_info['warning'] = 'stdio 模式不支持远程容器部署,需改为 SSE 模式'
466
+
467
+ return mcp_info
468
+
469
+ def _detect_mcp_transport(self, comp_path: Path, language: str) -> str:
470
+ """检测 MCP transport 模式"""
471
+ # 查找入口文件
472
+ entry_files = ['server.py', 'mcp_server.py', 'main.py'] if language == 'python' else ['server.js', 'server.ts', 'index.js', 'index.ts']
473
+
474
+ for entry_file in entry_files:
475
+ file_path = comp_path / entry_file
476
+ if not file_path.exists():
477
+ continue
478
+
479
+ try:
480
+ content = file_path.read_text(encoding='utf-8')
481
+
482
+ # 检查 SSE 模式
483
+ for pattern in MCP_TRANSPORT_PATTERNS.get(language, {}).get('sse', []):
484
+ if re.search(pattern, content):
485
+ return 'sse'
486
+
487
+ # 检查 stdio 模式
488
+ for pattern in MCP_TRANSPORT_PATTERNS.get(language, {}).get('stdio', []):
489
+ if re.search(pattern, content):
490
+ return 'stdio'
491
+
492
+ except Exception:
493
+ continue
494
+
495
+ # 默认假设 stdio(保守估计)
496
+ return 'stdio'
497
+
498
+ def _detect_mcp_port(self, comp_path: Path, language: str) -> Optional[int]:
499
+ """检测 MCP 端口"""
500
+ entry_files = ['server.py', 'mcp_server.py', 'main.py'] if language == 'python' else ['server.js', 'server.ts', 'index.js', 'index.ts']
501
+
502
+ for entry_file in entry_files:
503
+ file_path = comp_path / entry_file
504
+ if not file_path.exists():
505
+ continue
506
+
507
+ try:
508
+ content = file_path.read_text(encoding='utf-8')
509
+
510
+ # 匹配端口号
511
+ patterns = [
512
+ r'port\s*=\s*(\d+)',
513
+ r'port:\s*(\d+)',
514
+ r'PORT\s*=\s*(\d+)',
515
+ ]
516
+
517
+ for pattern in patterns:
518
+ match = re.search(pattern, content)
519
+ if match:
520
+ return int(match.group(1))
521
+
522
+ except Exception:
523
+ continue
524
+
525
+ return DETECTION_RULES.get('mcp', {}).get('default_port', 9192)
526
+
527
+ def _detect_start_script(self) -> Optional[str]:
528
+ """检测统一启动脚本"""
529
+ start_scripts = ['start.sh', 'run.sh', 'docker-entrypoint.sh']
530
+
531
+ for script in start_scripts:
532
+ if (self.project_path / script).exists():
533
+ return f'./{script}'
534
+
535
+ return None
536
+
537
+ # ============= 原有检测方法 =============
538
+
539
+ def _detect_by_dependencies(self) -> Optional[Dict]:
540
+ """通过 dependencies 检测框架"""
541
+ dependencies = {
542
+ **self.package_json.get('dependencies', {}),
543
+ **self.package_json.get('devDependencies', {})
544
+ }
545
+
546
+ for framework, rules in DETECTION_RULES.items():
547
+ required_deps = rules['dependencies']
548
+
549
+ # 检查是否所有必需依赖都存在
550
+ if all(dep in dependencies for dep in required_deps):
551
+ return {
552
+ 'framework': framework,
553
+ 'confidence': 100,
554
+ 'detection_method': 'dependencies'
555
+ }
556
+
557
+ return None
558
+
559
+ def _detect_by_scripts(self) -> Optional[Dict]:
560
+ """通过 scripts 检测框架"""
561
+ scripts = self.package_json.get('scripts', {})
562
+
563
+ for framework, rules in DETECTION_RULES.items():
564
+ patterns = rules['scripts_patterns']
565
+
566
+ # 检查是否有匹配的脚本模式
567
+ for script_value in scripts.values():
568
+ if any(pattern in script_value for pattern in patterns):
569
+ return {
570
+ 'framework': framework,
571
+ 'confidence': 90,
572
+ 'detection_method': 'scripts'
573
+ }
574
+
575
+ return None
576
+
577
+ def _detect_by_structure(self) -> Optional[Dict]:
578
+ """通过项目结构检测框架"""
579
+ for framework, rules in DETECTION_RULES.items():
580
+ # 检查配置文件
581
+ for file_name in rules['files']:
582
+ if (self.project_path / file_name).exists():
583
+ return {
584
+ 'framework': framework,
585
+ 'confidence': 80,
586
+ 'detection_method': 'structure'
587
+ }
588
+
589
+ # 检查目录结构
590
+ for dir_name in rules['dirs']:
591
+ if (self.project_path / dir_name).is_dir():
592
+ return {
593
+ 'framework': framework,
594
+ 'confidence': 75,
595
+ 'detection_method': 'structure'
596
+ }
597
+
598
+ return None
599
+
600
+ def _get_node_version(self) -> str:
601
+ """获取推荐的 Node.js 版本"""
602
+ engines = self.package_json.get('engines', {})
603
+ node_version = engines.get('node', '')
604
+
605
+ # 解析版本号(如 ">=18.0.0" -> "18")
606
+ if '>=' in node_version:
607
+ version = node_version.split('>=')[1].split('.')[0].strip()
608
+ return version
609
+ elif '^' in node_version or '~' in node_version:
610
+ version = node_version[1:].split('.')[0]
611
+ return version
612
+
613
+ # 默认返回 20
614
+ return '20'
615
+
616
+ def _get_build_command(self, framework: str) -> str:
617
+ """获取构建命令"""
618
+ scripts = self.package_json.get('scripts', {})
619
+
620
+ # 优先从 package.json 的 scripts 中获取
621
+ if 'build' in scripts:
622
+ return 'npm run build'
623
+
624
+ # 使用默认命令
625
+ return DETECTION_RULES[framework]['build_command']
626
+
627
+ def _get_start_command(self, framework: str) -> str:
628
+ """获取启动命令"""
629
+ scripts = self.package_json.get('scripts', {})
630
+
631
+ # 优先从 package.json 的 scripts 中获取
632
+ if 'start' in scripts:
633
+ return 'npm start'
634
+ elif 'preview' in scripts and framework.startswith('vite'):
635
+ return 'npm run preview'
636
+
637
+ # 使用默认命令
638
+ return DETECTION_RULES[framework]['start_command']
639
+
640
+ def _get_package_manager(self) -> str:
641
+ """检测包管理器"""
642
+ if (self.project_path / 'pnpm-lock.yaml').exists():
643
+ return 'pnpm'
644
+ elif (self.project_path / 'yarn.lock').exists():
645
+ return 'yarn'
646
+ else:
647
+ return 'npm'
648
+
649
+ def _parse_port_from_scripts(self) -> Optional[int]:
650
+ """从 package.json scripts 中解析端口"""
651
+ scripts = self.package_json.get('scripts', {})
652
+
653
+ # 检查常见的启动脚本
654
+ for script_name in ['start', 'preview', 'serve', 'prod']:
655
+ if script_name in scripts:
656
+ script_value = scripts[script_name]
657
+
658
+ # 匹配 -p 或 --port 参数
659
+ # 例如: "next start -p 3001" 或 "vite preview --port 4173"
660
+ patterns = [
661
+ r'-p\s+(\d+)', # -p 3000
662
+ r'--port[=\s]+(\d+)', # --port=3000 或 --port 3000
663
+ r'PORT=(\d+)', # PORT=3000
664
+ ]
665
+
666
+ for pattern in patterns:
667
+ match = re.search(pattern, script_value)
668
+ if match:
669
+ return int(match.group(1))
670
+
671
+ return None
672
+
673
+ def _parse_env_file(self, env_file: Path) -> Optional[int]:
674
+ """解析 .env 文件中的端口配置"""
675
+ if not env_file.exists():
676
+ return None
677
+
678
+ try:
679
+ with open(env_file, 'r', encoding='utf-8') as f:
680
+ for line in f:
681
+ line = line.strip()
682
+ # 跳过注释和空行
683
+ if not line or line.startswith('#'):
684
+ continue
685
+
686
+ # 匹配 PORT=3000 或 NEXT_PUBLIC_PORT=3000 等
687
+ match = re.match(r'^(?:NEXT_PUBLIC_)?PORT\s*=\s*(\d+)', line)
688
+ if match:
689
+ return int(match.group(1))
690
+ except Exception:
691
+ pass
692
+
693
+ return None
694
+
695
+ def _detect_port(self, framework: str) -> int:
696
+ """检测项目实际端口(按优先级)"""
697
+
698
+ # 1. 优先从 package.json scripts 中检测
699
+ port = self._parse_port_from_scripts()
700
+ if port:
701
+ return port
702
+
703
+ # 2. 检查环境变量文件(按优先级)
704
+ env_files = [
705
+ '.env.production.local',
706
+ '.env.production',
707
+ '.env.local',
708
+ '.env',
709
+ ]
710
+
711
+ for env_file_name in env_files:
712
+ env_file = self.project_path / env_file_name
713
+ port = self._parse_env_file(env_file)
714
+ if port:
715
+ return port
716
+
717
+ # 3. 尝试解析框架配置文件(Next.js 为例)
718
+ if framework == 'nextjs':
719
+ # 检查 next.config.js/mjs/ts 中的端口配置
720
+ # 注意:这里简化处理,因为完整解析 JS 配置文件比较复杂
721
+ # 实际项目中 Next.js 端口通常在 package.json scripts 或 .env 中配置
722
+ pass
723
+
724
+ # 4. 使用框架默认端口
725
+ return DETECTION_RULES[framework]['default_port']
726
+
727
+ def _success_result(self, detection: Dict) -> Dict:
728
+ """构建成功结果"""
729
+ framework = detection['framework']
730
+ language = detection.get('language', 'nodejs')
731
+
732
+ if language == 'python':
733
+ # Python 项目结果 (已在 _detect_python_framework 中构建)
734
+ return detection
735
+
736
+ # Node.js 项目结果 (保持原有逻辑)
737
+ result = {
738
+ 'success': True,
739
+ 'framework': framework,
740
+ 'confidence': detection['confidence'],
741
+ 'detection_method': detection['detection_method'],
742
+ 'node_version': self._get_node_version(),
743
+ 'build_command': self._get_build_command(framework),
744
+ 'start_command': self._get_start_command(framework),
745
+ 'package_manager': self._get_package_manager(),
746
+ 'port': self._detect_port(framework),
747
+ }
748
+
749
+ # Next.js 专属: 检测 Standalone 模式
750
+ if framework == 'nextjs':
751
+ standalone_info = self._detect_standalone()
752
+ if standalone_info:
753
+ result['standalone'] = standalone_info
754
+
755
+ return result
756
+
757
+ def _unknown_result(self) -> Dict:
758
+ """无法识别的结果"""
759
+ # 尝试从 scripts 或 .env 检测端口,如果检测不到使用默认 3000
760
+ port = self._parse_port_from_scripts()
761
+ if not port:
762
+ for env_file_name in ['.env.production.local', '.env.production', '.env.local', '.env']:
763
+ env_file = self.project_path / env_file_name
764
+ port = self._parse_env_file(env_file)
765
+ if port:
766
+ break
767
+ if not port:
768
+ port = 3000
769
+
770
+ return {
771
+ 'success': True,
772
+ 'framework': 'unknown',
773
+ 'confidence': 0,
774
+ 'detection_method': 'none',
775
+ 'node_version': self._get_node_version(),
776
+ 'build_command': self._get_build_command('express'), # 使用通用命令
777
+ 'start_command': self._get_start_command('express'),
778
+ 'package_manager': self._get_package_manager(),
779
+ 'port': port,
780
+ 'warning': 'Unable to detect framework type. Please provide build and start commands manually.'
781
+ }
782
+
783
+ def _error_result(self, message: str) -> Dict:
784
+ """构建错误结果"""
785
+ return {
786
+ 'success': False,
787
+ 'error': message
788
+ }
789
+
790
+ # ============= Python 检测方法 =============
791
+
792
+ def _detect_python_framework(self) -> Dict:
793
+ """检测 Python 框架"""
794
+ req_file = self.project_path / 'requirements.txt'
795
+
796
+ try:
797
+ req_content = req_file.read_text(encoding='utf-8')
798
+ except Exception as e:
799
+ return self._error_result(f"Failed to read requirements.txt: {e}")
800
+
801
+ # 1. 检查问题依赖
802
+ dep_warnings = self._check_problematic_dependencies(req_content)
803
+
804
+ # 2. 检测 Python 版本
805
+ python_version = self._detect_python_version()
806
+
807
+ # 3. 匹配框架(优先检测 MCP)
808
+ for framework, rules in DETECTION_RULES.items():
809
+ if rules.get('language') != 'python':
810
+ continue
811
+
812
+ # 检查依赖
813
+ for dep in rules['dependencies']:
814
+ if dep.lower() in req_content.lower():
815
+ # 查找入口文件
816
+ entry_file = None
817
+ for file in rules['files']:
818
+ if (self.project_path / file).exists():
819
+ entry_file = file
820
+ break
821
+
822
+ if not entry_file:
823
+ continue
824
+
825
+ # 检测端口(优先级:.env → config.py → 代码 → 默认值)
826
+ detected_port = self._detect_python_port(framework, entry_file)
827
+
828
+ # 检测启动命令(传入端口参数)
829
+ start_cmd_result = self._detect_start_command(framework, entry_file, detected_port)
830
+
831
+ # 构建环境变量 (orchestrator DeployParams 格式)
832
+ environment = {
833
+ 'PYTHON_VERSION': python_version # ← Python 版本标记
834
+ }
835
+
836
+ # 合并启动命令中的环境变量
837
+ if start_cmd_result.get('env'):
838
+ environment.update(start_cmd_result['env'])
839
+
840
+ result = {
841
+ 'success': True,
842
+ 'framework': framework,
843
+ 'language': 'python',
844
+ 'python_version': python_version,
845
+ 'entry_file': entry_file,
846
+ 'confidence': 100,
847
+ 'detection_method': 'dependencies',
848
+ 'build_command': None,
849
+ 'start_command': start_cmd_result.get('command'),
850
+ 'port': detected_port,
851
+
852
+ # ========== orchestrator DeployParams 字段 ==========
853
+ 'environment': environment, # ← 改名: env → environment
854
+ 'health_check_type': 'tcp', # ← Python 使用 TCP 健康检查
855
+ }
856
+
857
+ # 检测 MCP 特性
858
+ mcp_info = self._detect_mcp_feature(self.project_path, 'python', req_content)
859
+ if mcp_info:
860
+ result['features'] = ['mcp']
861
+ result['mcp'] = mcp_info
862
+ # 如果是 MCP 项目且检测到端口,使用 MCP 端口
863
+ if mcp_info.get('port'):
864
+ result['port'] = mcp_info['port']
865
+
866
+ # 添加依赖警告
867
+ if dep_warnings:
868
+ result['warnings'] = dep_warnings
869
+
870
+ # 添加启动命令提示
871
+ if start_cmd_result.get('hint'):
872
+ result['start_command_hint'] = start_cmd_result['hint']
873
+
874
+ # 添加启动命令警告(例如:没有 gunicorn 的提示)
875
+ if start_cmd_result.get('warning'):
876
+ if 'warnings' not in result:
877
+ result['warnings'] = []
878
+ result['warnings'].append(start_cmd_result['warning'])
879
+
880
+ return result
881
+
882
+ # 没有识别到支持的框架
883
+ return self._error_result(
884
+ "Python project detected but framework not recognized. "
885
+ "Only Flask, FastAPI, Streamlit, and MCP are supported."
886
+ )
887
+
888
+ def _detect_python_version(self) -> str:
889
+ """检测 Python 版本 (按优先级)"""
890
+ # 1. runtime.txt (Heroku 规范)
891
+ version = self._detect_from_runtime_txt()
892
+ if version:
893
+ return self._validate_python_version(version)
894
+
895
+ # 2. .python-version (pyenv)
896
+ version = self._detect_from_python_version()
897
+ if version:
898
+ return self._validate_python_version(version)
899
+
900
+ # 3. pyproject.toml
901
+ version = self._detect_from_pyproject_toml()
902
+ if version:
903
+ return self._validate_python_version(version)
904
+
905
+ # 4. 默认版本
906
+ return DEFAULT_PYTHON_VERSION
907
+
908
+ def _detect_from_runtime_txt(self) -> Optional[str]:
909
+ """从 runtime.txt 检测 (Heroku 规范)"""
910
+ runtime_file = self.project_path / 'runtime.txt'
911
+ if not runtime_file.exists():
912
+ return None
913
+
914
+ try:
915
+ content = runtime_file.read_text().strip()
916
+ # 匹配 "python-3.11" 或 "python-3.11.5"
917
+ match = re.match(r'python-(\d+\.\d+)(?:\.\d+)?', content)
918
+ if match:
919
+ return match.group(1)
920
+ except Exception:
921
+ pass
922
+
923
+ return None
924
+
925
+ def _detect_from_python_version(self) -> Optional[str]:
926
+ """从 .python-version 检测 (pyenv)"""
927
+ pyenv_file = self.project_path / '.python-version'
928
+ if not pyenv_file.exists():
929
+ return None
930
+
931
+ try:
932
+ content = pyenv_file.read_text().strip()
933
+ # 匹配 "3.11" 或 "3.11.5"
934
+ match = re.match(r'(\d+\.\d+)(?:\.\d+)?', content)
935
+ if match:
936
+ return match.group(1)
937
+ except Exception:
938
+ pass
939
+
940
+ return None
941
+
942
+ def _detect_from_pyproject_toml(self) -> Optional[str]:
943
+ """从 pyproject.toml 检测"""
944
+ pyproject_file = self.project_path / 'pyproject.toml'
945
+ if not pyproject_file.exists():
946
+ return None
947
+
948
+ try:
949
+ # 尝试导入 tomllib (Python 3.11+) 或 tomli
950
+ try:
951
+ import tomllib
952
+ except ImportError:
953
+ try:
954
+ import tomli as tomllib
955
+ except ImportError:
956
+ return None # 无法解析 TOML
957
+
958
+ content = pyproject_file.read_text()
959
+ data = tomllib.loads(content)
960
+
961
+ # 检查 [project] requires-python
962
+ if 'project' in data:
963
+ req = data['project'].get('requires-python', '')
964
+ match = re.search(r'(\d+\.\d+)', req)
965
+ if match:
966
+ return match.group(1)
967
+
968
+ # 检查 [tool.poetry] python 依赖
969
+ if 'tool' in data and 'poetry' in data['tool']:
970
+ deps = data['tool']['poetry'].get('dependencies', {})
971
+ python_dep = deps.get('python', '')
972
+ match = re.search(r'(\d+\.\d+)', str(python_dep))
973
+ if match:
974
+ return match.group(1)
975
+
976
+ except Exception:
977
+ pass
978
+
979
+ return None
980
+
981
+ def _validate_python_version(self, version: str) -> str:
982
+ """验证 Python 版本是否支持"""
983
+ if version in SUPPORTED_PYTHON_VERSIONS:
984
+ return version
985
+
986
+ # 版本不支持,输出警告并使用默认版本
987
+ print(f"⚠️ Detected Python {version}, but only {SUPPORTED_PYTHON_VERSIONS} are supported.", file=sys.stderr)
988
+ print(f" Using default version {DEFAULT_PYTHON_VERSION}", file=sys.stderr)
989
+ return DEFAULT_PYTHON_VERSION
990
+
991
+ def _check_problematic_dependencies(self, req_content: str) -> List[str]:
992
+ """检查需要系统依赖的库"""
993
+ warnings = []
994
+
995
+ for line in req_content.lower().split('\n'):
996
+ line = line.strip()
997
+ if not line or line.startswith('#'):
998
+ continue
999
+
1000
+ # 提取包名 (处理 package==1.0.0, package>=1.0 等格式)
1001
+ package = re.split(r'[=<>!]', line)[0].strip()
1002
+
1003
+ if package in SYSTEM_DEPENDENCY_LIBRARIES:
1004
+ reason = SYSTEM_DEPENDENCY_LIBRARIES[package]
1005
+ warnings.append(f"⚠️ {package}: {reason}")
1006
+
1007
+ return warnings
1008
+
1009
+ def _detect_start_command(self, framework: str, entry_file: str, port: int) -> Dict:
1010
+ """检测启动命令"""
1011
+ if framework == 'flask':
1012
+ return self._detect_flask_start_command(entry_file, port)
1013
+ elif framework == 'fastapi':
1014
+ return self._detect_fastapi_start_command(entry_file, port)
1015
+ elif framework == 'streamlit':
1016
+ return self._detect_streamlit_start_command(entry_file, port)
1017
+ elif framework == 'mcp':
1018
+ return self._detect_mcp_start_command(entry_file, port)
1019
+
1020
+ return {'command': None, 'hint': 'Unknown framework'}
1021
+
1022
+ def _detect_flask_start_command(self, entry_file: str, port: int) -> Dict:
1023
+ """检测 Flask 启动命令"""
1024
+ file_path = self.project_path / entry_file
1025
+ req_file = self.project_path / 'requirements.txt'
1026
+
1027
+ # 检查是否安装了 gunicorn
1028
+ has_gunicorn = False
1029
+ if req_file.exists():
1030
+ try:
1031
+ req_content = req_file.read_text(encoding='utf-8').lower()
1032
+ has_gunicorn = 'gunicorn' in req_content
1033
+ except Exception:
1034
+ pass
1035
+
1036
+ try:
1037
+ content = file_path.read_text(encoding='utf-8')
1038
+ except Exception:
1039
+ return {
1040
+ 'command': None,
1041
+ 'hint': f"Unable to read {entry_file}. Please manually specify the start command."
1042
+ }
1043
+
1044
+ # 查找 Flask app 实例名称
1045
+ match = re.search(r'(\w+)\s*=\s*Flask\s*\(', content)
1046
+ app_var = match.group(1) if match else 'app'
1047
+ module = entry_file.replace('.py', '')
1048
+
1049
+ # 方式 1: if __name__ == '__main__': app.run()
1050
+ # 这种方式通常在开发时使用,保持原样(代码内部控制端口)
1051
+ if "if __name__ == '__main__':" in content and 'app.run()' in content:
1052
+ return {'command': f"python {entry_file}"}
1053
+
1054
+ # 方式 2: 优先使用 gunicorn (生产环境)
1055
+ if has_gunicorn and match:
1056
+ return {
1057
+ 'command': f'gunicorn -b 0.0.0.0:{port} {module}:{app_var}',
1058
+ 'env': {}
1059
+ }
1060
+
1061
+ # 方式 3: 降级到 flask run (开发环境)
1062
+ if match:
1063
+ result = {
1064
+ 'command': f'flask run --host=0.0.0.0 --port={port}',
1065
+ 'env': {'FLASK_APP': entry_file}
1066
+ }
1067
+ # 如果没有 gunicorn,给出警告
1068
+ if not has_gunicorn:
1069
+ result['warning'] = '⚠️ Using Flask development server. For production, add "gunicorn" to requirements.txt'
1070
+ return result
1071
+
1072
+ # 无法检测
1073
+ return {
1074
+ 'command': None,
1075
+ 'hint': (
1076
+ f"Unable to auto-detect Flask startup command. Common methods:\n"
1077
+ f" - python {entry_file} (if you have 'if __name__ == \"__main__\"')\n"
1078
+ f" - flask run --host=0.0.0.0 --port={port} (requires setting FLASK_APP={entry_file})\n"
1079
+ f" - gunicorn -b 0.0.0.0:{port} {module}:app (production environment)"
1080
+ )
1081
+ }
1082
+
1083
+ def _detect_fastapi_start_command(self, entry_file: str, port: int) -> Dict:
1084
+ """检测 FastAPI 启动命令"""
1085
+ file_path = self.project_path / entry_file
1086
+
1087
+ try:
1088
+ content = file_path.read_text(encoding='utf-8')
1089
+ except Exception:
1090
+ return {
1091
+ 'command': None,
1092
+ 'hint': f"Unable to read {entry_file}. Please manually specify the start command."
1093
+ }
1094
+
1095
+ # 查找 app = FastAPI() 的变量名
1096
+ match = re.search(r'(\w+)\s*=\s*FastAPI\s*\(', content)
1097
+ if match:
1098
+ app_var = match.group(1)
1099
+ module = entry_file.replace('.py', '')
1100
+ return {
1101
+ 'command': f"uvicorn {module}:{app_var} --host 0.0.0.0 --port {port}"
1102
+ }
1103
+
1104
+ # 无法检测
1105
+ module = entry_file.replace('.py', '')
1106
+ return {
1107
+ 'command': None,
1108
+ 'hint': (
1109
+ f"Unable to auto-detect FastAPI application instance name. Please provide start command:\n"
1110
+ f" Example: uvicorn {module}:app --host 0.0.0.0 --port {port}"
1111
+ )
1112
+ }
1113
+
1114
+ def _detect_streamlit_start_command(self, entry_file: str, port: int) -> Dict:
1115
+ """检测 Streamlit 启动命令"""
1116
+ # Streamlit 格式固定,使用检测到的端口
1117
+ return {
1118
+ 'command': f"streamlit run {entry_file} --server.port {port} --server.address 0.0.0.0"
1119
+ }
1120
+
1121
+ def _detect_mcp_start_command(self, entry_file: str, port: int) -> Dict:
1122
+ """检测 MCP Server 启动命令"""
1123
+ # MCP Server 直接运行 Python 脚本
1124
+ return {
1125
+ 'command': f"python {entry_file}"
1126
+ }
1127
+
1128
+ # ============= Python 端口检测方法 =============
1129
+
1130
+ def _detect_python_port(self, framework: str, entry_file: str) -> int:
1131
+ """
1132
+ 检测 Python 项目端口(按优先级)
1133
+
1134
+ 优先级:
1135
+ 1. .env 文件中的框架特定变量 (FLASK_PORT=3004)
1136
+ 2. .env 文件中的通用变量 (PORT=3004)
1137
+ 3. config.py 中的默认值 (FLASK_PORT = int(os.getenv('FLASK_PORT', 6101)))
1138
+ 4. app.py 中的硬编码值 (app.run(port=8080))
1139
+ 5. 框架默认值 (5000)
1140
+ """
1141
+
1142
+ # 1. 从 .env 文件检测
1143
+ env_files = ['.env.production', '.env.local', '.env']
1144
+ for env_file_name in env_files:
1145
+ env_file = self.project_path / env_file_name
1146
+ port = self._parse_python_env_port(env_file, framework)
1147
+ if port:
1148
+ return port
1149
+
1150
+ # 2. 从 config.py 检测默认值
1151
+ port = self._parse_config_py_port(framework)
1152
+ if port:
1153
+ return port
1154
+
1155
+ # 3. 从入口文件检测硬编码值
1156
+ port = self._parse_port_from_python_code(entry_file, framework)
1157
+ if port:
1158
+ return port
1159
+
1160
+ # 4. 使用框架默认端口
1161
+ return DETECTION_RULES[framework]['default_port']
1162
+
1163
+ def _parse_python_env_port(self, env_file: Path, framework: str) -> Optional[int]:
1164
+ """解析 .env 文件中的 Python 端口"""
1165
+ if not env_file.exists():
1166
+ return None
1167
+
1168
+ try:
1169
+ with open(env_file, 'r', encoding='utf-8') as f:
1170
+ for line in f:
1171
+ line = line.strip()
1172
+ if not line or line.startswith('#'):
1173
+ continue
1174
+
1175
+ # 优先匹配框架特定变量
1176
+ framework_patterns = {
1177
+ 'flask': r'^FLASK_PORT\s*=\s*(\d+)',
1178
+ 'fastapi': r'^(?:FASTAPI_|UVICORN_)?PORT\s*=\s*(\d+)',
1179
+ 'streamlit': r'^STREAMLIT_(?:SERVER_)?PORT\s*=\s*(\d+)',
1180
+ }
1181
+
1182
+ pattern = framework_patterns.get(framework)
1183
+ if pattern:
1184
+ match = re.match(pattern, line)
1185
+ if match:
1186
+ return int(match.group(1))
1187
+
1188
+ # 回退:匹配通用 PORT 变量
1189
+ match = re.match(r'^PORT\s*=\s*(\d+)', line)
1190
+ if match:
1191
+ return int(match.group(1))
1192
+ except Exception:
1193
+ pass
1194
+
1195
+ return None
1196
+
1197
+ def _parse_config_py_port(self, framework: str) -> Optional[int]:
1198
+ """解析 config.py 中的端口默认值"""
1199
+ config_file = self.project_path / 'config.py'
1200
+ if not config_file.exists():
1201
+ return None
1202
+
1203
+ try:
1204
+ content = config_file.read_text(encoding='utf-8')
1205
+
1206
+ # 匹配 os.getenv() 模式: FLASK_PORT = int(os.getenv('FLASK_PORT', 6101))
1207
+ getenv_patterns = {
1208
+ 'flask': r'FLASK_PORT\s*=\s*int\s*\(\s*os\.getenv\s*\([^,]+,\s*(\d+)\s*\)\s*\)',
1209
+ 'fastapi': r'(?:FASTAPI_|UVICORN_)?PORT\s*=\s*int\s*\(\s*os\.getenv\s*\([^,]+,\s*(\d+)\s*\)\s*\)',
1210
+ 'streamlit': r'STREAMLIT_PORT\s*=\s*int\s*\(\s*os\.getenv\s*\([^,]+,\s*(\d+)\s*\)\s*\)',
1211
+ }
1212
+
1213
+ pattern = getenv_patterns.get(framework)
1214
+ if pattern:
1215
+ match = re.search(pattern, content)
1216
+ if match:
1217
+ return int(match.group(1))
1218
+
1219
+ # 匹配直接赋值模式: FLASK_PORT = 6101
1220
+ direct_patterns = {
1221
+ 'flask': r'FLASK_PORT\s*=\s*(\d+)',
1222
+ 'fastapi': r'(?:FASTAPI_|UVICORN_)?PORT\s*=\s*(\d+)',
1223
+ 'streamlit': r'STREAMLIT_PORT\s*=\s*(\d+)',
1224
+ }
1225
+
1226
+ pattern = direct_patterns.get(framework)
1227
+ if pattern:
1228
+ match = re.search(pattern, content)
1229
+ if match:
1230
+ return int(match.group(1))
1231
+
1232
+ except Exception:
1233
+ pass
1234
+
1235
+ return None
1236
+
1237
+ def _parse_port_from_python_code(self, entry_file: str, framework: str) -> Optional[int]:
1238
+ """从 Python 代码中解析端口配置"""
1239
+ file_path = self.project_path / entry_file
1240
+
1241
+ try:
1242
+ content = file_path.read_text(encoding='utf-8')
1243
+
1244
+ if framework == 'flask':
1245
+ # 匹配 app.run(port=5000) 或 app.run(host='0.0.0.0', port=5000)
1246
+ match = re.search(r'app\.run\s*\([^)]*port\s*=\s*(\d+)', content)
1247
+ if match:
1248
+ return int(match.group(1))
1249
+
1250
+ elif framework == 'fastapi':
1251
+ # 匹配 uvicorn.run("main:app", port=8000)
1252
+ match = re.search(r'uvicorn\.run\s*\([^)]*port\s*=\s*(\d+)', content)
1253
+ if match:
1254
+ return int(match.group(1))
1255
+
1256
+ elif framework == 'streamlit':
1257
+ # Streamlit 端口通常在 .streamlit/config.toml 中
1258
+ config_file = self.project_path / '.streamlit' / 'config.toml'
1259
+ if config_file.exists():
1260
+ config_content = config_file.read_text()
1261
+ match = re.search(r'port\s*=\s*(\d+)', config_content)
1262
+ if match:
1263
+ return int(match.group(1))
1264
+
1265
+ except Exception:
1266
+ pass
1267
+
1268
+ return None
1269
+
1270
+ # ============= Next.js Standalone 检测方法 =============
1271
+
1272
+ def _detect_standalone(self) -> Optional[Dict]:
1273
+ """
1274
+ 检测 Next.js Standalone 模式配置
1275
+
1276
+ 返回:
1277
+ {
1278
+ 'enabled': bool, # 是否已启用 standalone
1279
+ 'compatible': bool, # Next.js 版本是否兼容 (>=12.0)
1280
+ 'configFile': str, # 配置文件路径
1281
+ 'hasOutputConfig': bool # 是否已有 output 配置(任何值)
1282
+ }
1283
+ """
1284
+ # 1. 查找 next.config 文件
1285
+ config_files = ['next.config.js', 'next.config.mjs', 'next.config.ts']
1286
+ config_file = None
1287
+ config_content = None
1288
+
1289
+ for filename in config_files:
1290
+ file_path = self.project_path / filename
1291
+ if file_path.exists():
1292
+ config_file = filename
1293
+ try:
1294
+ config_content = file_path.read_text(encoding='utf-8')
1295
+ except Exception:
1296
+ pass
1297
+ break
1298
+
1299
+ # 2. 检测 Next.js 版本兼容性 (>=12.0)
1300
+ compatible = self._check_nextjs_version_compatible()
1301
+
1302
+ # 3. 检测是否有自定义 server(不适用 Standalone)
1303
+ has_custom_server = (
1304
+ (self.project_path / 'server.js').exists() or
1305
+ (self.project_path / 'server.ts').exists()
1306
+ )
1307
+
1308
+ # 4. 解析配置文件
1309
+ enabled = False
1310
+ has_output_config = False
1311
+
1312
+ if config_content:
1313
+ # 检测 output: 'standalone'
1314
+ if re.search(r"output\s*:\s*['\"]standalone['\"]", config_content):
1315
+ enabled = True
1316
+ has_output_config = True
1317
+ # 检测其他 output 配置 (如 'export')
1318
+ elif re.search(r"output\s*:\s*['\"](\w+)['\"]", config_content):
1319
+ has_output_config = True
1320
+
1321
+ # 5. 构建返回结果
1322
+ result = {
1323
+ 'enabled': enabled,
1324
+ 'compatible': compatible,
1325
+ 'configFile': config_file,
1326
+ 'hasOutputConfig': has_output_config,
1327
+ }
1328
+
1329
+ # 6. 判断是否应该建议启用
1330
+ # 条件: 未启用 + 版本兼容 + 无其他 output 配置 + 无自定义 server
1331
+ should_suggest = (
1332
+ not enabled and
1333
+ compatible and
1334
+ not has_output_config and
1335
+ not has_custom_server
1336
+ )
1337
+ result['shouldSuggest'] = should_suggest
1338
+
1339
+ if has_custom_server:
1340
+ result['skipReason'] = 'custom_server'
1341
+ elif not compatible:
1342
+ result['skipReason'] = 'version_incompatible'
1343
+ elif has_output_config and not enabled:
1344
+ result['skipReason'] = 'other_output_mode'
1345
+
1346
+ return result
1347
+
1348
+ def _check_nextjs_version_compatible(self) -> bool:
1349
+ """检查 Next.js 版本是否支持 Standalone (>=12.0)"""
1350
+ if not self.package_json:
1351
+ return False
1352
+
1353
+ dependencies = {
1354
+ **self.package_json.get('dependencies', {}),
1355
+ **self.package_json.get('devDependencies', {})
1356
+ }
1357
+
1358
+ next_version = dependencies.get('next', '')
1359
+
1360
+ # 处理各种版本格式
1361
+ # ^14.0.0, ~13.5.0, >=12.0.0, 14.0.0, latest, canary
1362
+ if not next_version or next_version in ['latest', 'canary', 'next']:
1363
+ # 无法确定版本,保守返回 False
1364
+ return False
1365
+
1366
+ # 提取主版本号
1367
+ match = re.search(r'(\d+)', next_version)
1368
+ if match:
1369
+ major_version = int(match.group(1))
1370
+ return major_version >= 12
1371
+
1372
+ return False
1373
+
1374
+
1375
+ def main():
1376
+ if len(sys.argv) < 2:
1377
+ print(json.dumps({
1378
+ 'success': False,
1379
+ 'error': 'Usage: detect_framework.py <project_path>'
1380
+ }, indent=2))
1381
+ sys.exit(1)
1382
+
1383
+ project_path = sys.argv[1]
1384
+
1385
+ # 检查路径是否存在
1386
+ if not os.path.exists(project_path):
1387
+ print(json.dumps({
1388
+ 'success': False,
1389
+ 'error': f'Project path does not exist: {project_path}'
1390
+ }, indent=2))
1391
+ sys.exit(1)
1392
+
1393
+ # 执行检测
1394
+ detector = FrameworkDetector(project_path)
1395
+ result = detector.detect()
1396
+
1397
+ # 输出 JSON 结果
1398
+ print(json.dumps(result, indent=2))
1399
+
1400
+ # 如果检测失败,返回非零退出码
1401
+ if not result.get('success', False):
1402
+ sys.exit(1)
1403
+
1404
+
1405
+ if __name__ == '__main__':
1406
+ main()