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.
- mobox/__init__.py +1 -0
- mobox/auth.py +254 -0
- mobox/client.py +98 -0
- mobox/commands/__init__.py +0 -0
- mobox/commands/deploy.py +149 -0
- mobox/commands/local.py +82 -0
- mobox/commands/manage.py +188 -0
- mobox/commands/ops.py +125 -0
- mobox/config.py +82 -0
- mobox/local/__init__.py +0 -0
- mobox/local/analyze_volumes.py +483 -0
- mobox/local/config_utils.py +632 -0
- mobox/local/detect_framework.py +1406 -0
- mobox/local/detect_project.py +388 -0
- mobox/local/pack_project.py +543 -0
- mobox/main.py +109 -0
- mobox/utils.py +52 -0
- mobox_cli-0.1.0.dist-info/METADATA +9 -0
- mobox_cli-0.1.0.dist-info/RECORD +21 -0
- mobox_cli-0.1.0.dist-info/WHEEL +4 -0
- mobox_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Config Utils - 统一配置工具库
|
|
4
|
+
|
|
5
|
+
提供配置文件的读取、验证、写入和智能合并功能
|
|
6
|
+
支持 hybrid 类型和 MCP 特性
|
|
7
|
+
|
|
8
|
+
版本: v1.1.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_config(config: Dict) -> Dict:
|
|
18
|
+
"""
|
|
19
|
+
验证配置文件格式(支持 hybrid 类型)
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: 配置对象
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
验证结果 {'valid': bool, 'error': str}
|
|
26
|
+
"""
|
|
27
|
+
# 检查基础必需字段
|
|
28
|
+
basic_required = ['version', 'appName', 'appVersion', 'framework']
|
|
29
|
+
for field in basic_required:
|
|
30
|
+
if field not in config:
|
|
31
|
+
return {
|
|
32
|
+
'valid': False,
|
|
33
|
+
'error': f'Missing required field: {field}'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
framework = config.get('framework')
|
|
37
|
+
|
|
38
|
+
# hybrid 类型验证
|
|
39
|
+
if framework == 'hybrid':
|
|
40
|
+
return _validate_hybrid_config(config)
|
|
41
|
+
|
|
42
|
+
# 单一框架类型验证
|
|
43
|
+
return _validate_single_framework_config(config)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _validate_hybrid_config(config: Dict) -> Dict:
|
|
47
|
+
"""验证 hybrid 类型配置"""
|
|
48
|
+
# 检查 components
|
|
49
|
+
if 'components' not in config:
|
|
50
|
+
return {
|
|
51
|
+
'valid': False,
|
|
52
|
+
'error': 'Missing required field: components (required for hybrid framework)'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
components = config.get('components', [])
|
|
56
|
+
if not isinstance(components, list) or len(components) == 0:
|
|
57
|
+
return {
|
|
58
|
+
'valid': False,
|
|
59
|
+
'error': 'components must be a non-empty array'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# 验证每个 component
|
|
63
|
+
has_primary = False
|
|
64
|
+
for i, comp in enumerate(components):
|
|
65
|
+
# 必需字段
|
|
66
|
+
for field in ['path', 'language', 'framework']:
|
|
67
|
+
if field not in comp:
|
|
68
|
+
return {
|
|
69
|
+
'valid': False,
|
|
70
|
+
'error': f'components[{i}] missing required field: {field}'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# 语言验证
|
|
74
|
+
if comp['language'] not in ['python', 'nodejs']:
|
|
75
|
+
return {
|
|
76
|
+
'valid': False,
|
|
77
|
+
'error': f'components[{i}].language must be "python" or "nodejs"'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# 端口验证(如果存在)
|
|
81
|
+
if 'port' in comp:
|
|
82
|
+
port = comp['port']
|
|
83
|
+
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
84
|
+
return {
|
|
85
|
+
'valid': False,
|
|
86
|
+
'error': f'components[{i}].port must be between 1-65535'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# primary 标记
|
|
90
|
+
if comp.get('primary'):
|
|
91
|
+
has_primary = True
|
|
92
|
+
|
|
93
|
+
# 检查 runtime
|
|
94
|
+
if 'runtime' not in config:
|
|
95
|
+
return {
|
|
96
|
+
'valid': False,
|
|
97
|
+
'error': 'Missing required field: runtime (required for hybrid framework)'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
runtime = config.get('runtime', {})
|
|
101
|
+
if 'requires' not in runtime:
|
|
102
|
+
return {
|
|
103
|
+
'valid': False,
|
|
104
|
+
'error': 'Missing runtime.requires (required for hybrid framework)'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
requires = runtime.get('requires', [])
|
|
108
|
+
if not isinstance(requires, list) or len(requires) == 0:
|
|
109
|
+
return {
|
|
110
|
+
'valid': False,
|
|
111
|
+
'error': 'runtime.requires must be a non-empty array'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# 验证 volumes(如果存在)
|
|
115
|
+
volumes_validation = _validate_volumes(config.get('volumes', []))
|
|
116
|
+
if not volumes_validation['valid']:
|
|
117
|
+
return volumes_validation
|
|
118
|
+
|
|
119
|
+
return {'valid': True}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_single_framework_config(config: Dict) -> Dict:
|
|
123
|
+
"""验证单一框架类型配置"""
|
|
124
|
+
# 检查必需字段
|
|
125
|
+
required_fields = ['build', 'runtime']
|
|
126
|
+
for field in required_fields:
|
|
127
|
+
if field not in config:
|
|
128
|
+
return {
|
|
129
|
+
'valid': False,
|
|
130
|
+
'error': f'Missing required field: {field}'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# 检查 build 配置
|
|
134
|
+
build = config.get('build', {})
|
|
135
|
+
if 'command' not in build:
|
|
136
|
+
return {
|
|
137
|
+
'valid': False,
|
|
138
|
+
'error': 'Missing build.command'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# 允许 build.command 为空字符串(Python 项目无需构建)
|
|
142
|
+
build_command = build.get('command')
|
|
143
|
+
if build_command is not None and not isinstance(build_command, str):
|
|
144
|
+
return {
|
|
145
|
+
'valid': False,
|
|
146
|
+
'error': 'build.command must be a string or null'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# 检查 runtime 配置
|
|
150
|
+
runtime = config.get('runtime', {})
|
|
151
|
+
required_runtime_fields = ['command', 'port']
|
|
152
|
+
for field in required_runtime_fields:
|
|
153
|
+
if field not in runtime:
|
|
154
|
+
return {
|
|
155
|
+
'valid': False,
|
|
156
|
+
'error': f'Missing runtime.{field}'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# nodeVersion 对 Node.js 项目是必需的,但对 Python 项目可选
|
|
160
|
+
if 'nodeVersion' in runtime:
|
|
161
|
+
node_version = runtime.get('nodeVersion')
|
|
162
|
+
if node_version is not None and not isinstance(node_version, str):
|
|
163
|
+
return {
|
|
164
|
+
'valid': False,
|
|
165
|
+
'error': 'runtime.nodeVersion must be a string or null'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# 检查端口范围
|
|
169
|
+
port = runtime.get('port')
|
|
170
|
+
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
171
|
+
return {
|
|
172
|
+
'valid': False,
|
|
173
|
+
'error': f'Invalid port: {port}. Must be between 1-65535'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# 验证 volumes
|
|
177
|
+
volumes_validation = _validate_volumes(config.get('volumes', []))
|
|
178
|
+
if not volumes_validation['valid']:
|
|
179
|
+
return volumes_validation
|
|
180
|
+
|
|
181
|
+
return {'valid': True}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validate_volumes(volumes: List) -> Dict:
|
|
185
|
+
"""验证 volumes 配置"""
|
|
186
|
+
if not isinstance(volumes, list):
|
|
187
|
+
return {
|
|
188
|
+
'valid': False,
|
|
189
|
+
'error': 'volumes must be an array'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for i, volume in enumerate(volumes):
|
|
193
|
+
if 'source' not in volume:
|
|
194
|
+
return {
|
|
195
|
+
'valid': False,
|
|
196
|
+
'error': f'volumes[{i}] missing source field'
|
|
197
|
+
}
|
|
198
|
+
if not volume['source'].startswith('./'):
|
|
199
|
+
return {
|
|
200
|
+
'valid': False,
|
|
201
|
+
'error': f'volumes[{i}].source must start with "./"'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# ========== 安全检查:禁止危险路径 ==========
|
|
205
|
+
source = volume['source']
|
|
206
|
+
dangerous_patterns = [".", "./.", "..", "/"]
|
|
207
|
+
|
|
208
|
+
# 标准化路径检查
|
|
209
|
+
normalized = str(Path(source).as_posix())
|
|
210
|
+
|
|
211
|
+
if normalized in dangerous_patterns or normalized.startswith("../"):
|
|
212
|
+
return {
|
|
213
|
+
'valid': False,
|
|
214
|
+
'error': f'volumes[{i}].source="{source}" is dangerous. '
|
|
215
|
+
f'Cannot mount project root, parent directory, or root. '
|
|
216
|
+
f'Please specify a subdirectory or file (e.g., "./data", "./app.db")'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if 'priority' in volume and volume['priority'] not in ['high', 'medium', 'low']:
|
|
220
|
+
return {
|
|
221
|
+
'valid': False,
|
|
222
|
+
'error': f'volumes[{i}].priority must be "high", "medium", or "low"'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {'valid': True}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_config(project_path: str) -> Optional[Dict]:
|
|
229
|
+
"""
|
|
230
|
+
读取并验证 app-deploy.json 配置文件
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
project_path: 项目路径
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
配置字典,如果文件不存在返回 None
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ValueError: 配置无效或格式错误
|
|
240
|
+
"""
|
|
241
|
+
config_path = Path(project_path).resolve() / 'app-deploy.json'
|
|
242
|
+
|
|
243
|
+
# 检查配置文件是否存在
|
|
244
|
+
if not config_path.exists():
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
# 读取配置文件
|
|
249
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
250
|
+
config = json.load(f)
|
|
251
|
+
|
|
252
|
+
# 自动验证配置
|
|
253
|
+
validation_result = validate_config(config)
|
|
254
|
+
if not validation_result['valid']:
|
|
255
|
+
raise ValueError(validation_result['error'])
|
|
256
|
+
|
|
257
|
+
return config
|
|
258
|
+
|
|
259
|
+
except json.JSONDecodeError as e:
|
|
260
|
+
raise ValueError(f'Invalid JSON format: {str(e)}')
|
|
261
|
+
except Exception as e:
|
|
262
|
+
raise ValueError(f'Failed to read config file: {str(e)}')
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def write_config(project_path: str, config: Dict) -> None:
|
|
266
|
+
"""
|
|
267
|
+
写入配置文件前自动验证
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
project_path: 项目路径
|
|
271
|
+
config: 配置字典
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
ValueError: 配置无效或写入失败
|
|
275
|
+
"""
|
|
276
|
+
# 验证配置
|
|
277
|
+
validation_result = validate_config(config)
|
|
278
|
+
if not validation_result['valid']:
|
|
279
|
+
raise ValueError(validation_result['error'])
|
|
280
|
+
|
|
281
|
+
config_path = Path(project_path).resolve() / 'app-deploy.json'
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
285
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
raise ValueError(f'Failed to write config file: {str(e)}')
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def compare_detection(existing: Dict, detection: Dict) -> bool:
|
|
291
|
+
"""
|
|
292
|
+
比对检测结果是否变化(支持 hybrid 类型)
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
existing: 现有配置
|
|
296
|
+
detection: 新的检测结果
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
True: 有变化,需要更新
|
|
300
|
+
False: 无变化,跳过写入
|
|
301
|
+
"""
|
|
302
|
+
if not existing:
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
# 框架类型变化
|
|
306
|
+
if existing.get('framework') != detection.get('framework'):
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
# hybrid 类型比对
|
|
310
|
+
if detection.get('framework') == 'hybrid':
|
|
311
|
+
checks = [
|
|
312
|
+
_components_changed(existing.get('components', []), detection.get('components', [])),
|
|
313
|
+
existing.get('runtime', {}).get('command') != detection.get('runtime', {}).get('command'),
|
|
314
|
+
set(existing.get('runtime', {}).get('requires', [])) != set(detection.get('runtime', {}).get('requires', [])),
|
|
315
|
+
existing.get('features', []) != detection.get('features', []),
|
|
316
|
+
_volumes_changed(existing.get('volumes', []), detection.get('volumes', []))
|
|
317
|
+
]
|
|
318
|
+
else:
|
|
319
|
+
# 单一框架类型比对
|
|
320
|
+
checks = [
|
|
321
|
+
existing.get('build', {}).get('command') != detection.get('build', {}).get('command'),
|
|
322
|
+
existing.get('runtime', {}).get('command') != detection.get('runtime', {}).get('command'),
|
|
323
|
+
existing.get('runtime', {}).get('port') != detection.get('runtime', {}).get('port'),
|
|
324
|
+
existing.get('runtime', {}).get('nodeVersion') != detection.get('runtime', {}).get('nodeVersion'),
|
|
325
|
+
existing.get('features', []) != detection.get('features', []),
|
|
326
|
+
_volumes_changed(existing.get('volumes', []), detection.get('volumes', []))
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
return any(checks)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _components_changed(old: List[Dict], new: List[Dict]) -> bool:
|
|
333
|
+
"""比对 components 是否变化"""
|
|
334
|
+
if len(old) != len(new):
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
# 按 path 排序后比对
|
|
338
|
+
old_sorted = sorted(old, key=lambda x: x.get('path', ''))
|
|
339
|
+
new_sorted = sorted(new, key=lambda x: x.get('path', ''))
|
|
340
|
+
|
|
341
|
+
for o, n in zip(old_sorted, new_sorted):
|
|
342
|
+
if (o.get('path') != n.get('path') or
|
|
343
|
+
o.get('language') != n.get('language') or
|
|
344
|
+
o.get('framework') != n.get('framework') or
|
|
345
|
+
o.get('port') != n.get('port')):
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _volumes_changed(old: List[Dict], new: List[Dict]) -> bool:
|
|
352
|
+
"""
|
|
353
|
+
比对卷是否变化(仅比对检测到的卷)
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
old: 旧的卷列表
|
|
357
|
+
new: 新的卷列表
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
True: 有变化
|
|
361
|
+
"""
|
|
362
|
+
old_set = {(v['source'], v.get('reason', ''), v.get('priority', 'medium')) for v in old}
|
|
363
|
+
new_set = {(v['source'], v.get('reason', ''), v.get('priority', 'medium')) for v in new}
|
|
364
|
+
return old_set != new_set
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def merge_config(existing: Optional[Dict], detection: Dict, project_path: str = None) -> Dict:
|
|
368
|
+
"""
|
|
369
|
+
智能合并配置(保留用户字段,支持 hybrid 类型)
|
|
370
|
+
|
|
371
|
+
保留字段:
|
|
372
|
+
- appName
|
|
373
|
+
- appVersion
|
|
374
|
+
- optimization.*
|
|
375
|
+
- 用户添加的自定义字段
|
|
376
|
+
|
|
377
|
+
更新字段:
|
|
378
|
+
- framework
|
|
379
|
+
- build.* / components (取决于类型)
|
|
380
|
+
- runtime.*
|
|
381
|
+
- features
|
|
382
|
+
- mcp
|
|
383
|
+
- volumes(增量合并,自动过滤已删除的目录)
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
existing: 现有配置(None 表示新项目)
|
|
387
|
+
detection: 检测结果
|
|
388
|
+
project_path: 项目路径(用于验证卷目录是否存在)
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
合并后的配置
|
|
392
|
+
"""
|
|
393
|
+
merged = existing.copy() if existing else {}
|
|
394
|
+
|
|
395
|
+
# 更新框架类型
|
|
396
|
+
merged['framework'] = detection['framework']
|
|
397
|
+
|
|
398
|
+
# 根据类型更新不同字段
|
|
399
|
+
if detection['framework'] == 'hybrid':
|
|
400
|
+
# hybrid 类型
|
|
401
|
+
merged['components'] = detection['components']
|
|
402
|
+
merged['runtime'] = detection['runtime']
|
|
403
|
+
# 移除单一框架类型的字段
|
|
404
|
+
merged.pop('build', None)
|
|
405
|
+
else:
|
|
406
|
+
# 单一框架类型
|
|
407
|
+
merged['build'] = detection['build']
|
|
408
|
+
merged['runtime'] = detection['runtime']
|
|
409
|
+
# 移除 hybrid 类型的字段
|
|
410
|
+
merged.pop('components', None)
|
|
411
|
+
|
|
412
|
+
# 更新 features
|
|
413
|
+
if 'features' in detection:
|
|
414
|
+
merged['features'] = detection['features']
|
|
415
|
+
elif 'features' in merged and 'features' not in detection:
|
|
416
|
+
merged.pop('features', None)
|
|
417
|
+
|
|
418
|
+
# 更新 mcp 配置
|
|
419
|
+
if 'mcp' in detection:
|
|
420
|
+
merged['mcp'] = detection['mcp']
|
|
421
|
+
elif 'mcp' in merged and 'mcp' not in detection:
|
|
422
|
+
merged.pop('mcp', None)
|
|
423
|
+
|
|
424
|
+
# 卷增量合并(保留用户手动添加的卷,但过滤已删除的目录)
|
|
425
|
+
detected_sources = {v['source'] for v in detection.get('volumes', [])}
|
|
426
|
+
user_volumes = [v for v in merged.get('volumes', [])
|
|
427
|
+
if v['source'] not in detected_sources]
|
|
428
|
+
|
|
429
|
+
# 验证用户卷目录是否仍然存在
|
|
430
|
+
if project_path and user_volumes:
|
|
431
|
+
project = Path(project_path).resolve()
|
|
432
|
+
valid_user_volumes = []
|
|
433
|
+
removed_volumes = []
|
|
434
|
+
|
|
435
|
+
for vol in user_volumes:
|
|
436
|
+
source = vol['source'].lstrip('./')
|
|
437
|
+
vol_path = project / source
|
|
438
|
+
if vol_path.exists():
|
|
439
|
+
valid_user_volumes.append(vol)
|
|
440
|
+
else:
|
|
441
|
+
removed_volumes.append(vol['source'])
|
|
442
|
+
|
|
443
|
+
if removed_volumes:
|
|
444
|
+
print(f" ℹ️ Removed {len(removed_volumes)} non-existent volume(s):", file=sys.stderr)
|
|
445
|
+
for src in removed_volumes:
|
|
446
|
+
print(f" - {src}", file=sys.stderr)
|
|
447
|
+
|
|
448
|
+
user_volumes = valid_user_volumes
|
|
449
|
+
|
|
450
|
+
merged['volumes'] = detection.get('volumes', []) + user_volumes
|
|
451
|
+
|
|
452
|
+
# 保留用户字段
|
|
453
|
+
if not merged.get('appName'):
|
|
454
|
+
merged['appName'] = ""
|
|
455
|
+
if not merged.get('appVersion'):
|
|
456
|
+
merged['appVersion'] = "1.0.0"
|
|
457
|
+
if not merged.get('version'):
|
|
458
|
+
merged['version'] = "1.0"
|
|
459
|
+
|
|
460
|
+
# 保留 optimization 配置(用户自定义)
|
|
461
|
+
if existing and 'optimization' in existing:
|
|
462
|
+
merged['optimization'] = existing['optimization']
|
|
463
|
+
|
|
464
|
+
return merged
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def get_change_summary(existing: Optional[Dict], detection: Dict) -> List[str]:
|
|
468
|
+
"""
|
|
469
|
+
获取变更摘要
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
existing: 现有配置
|
|
473
|
+
detection: 新的检测结果
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
变更描述列表
|
|
477
|
+
"""
|
|
478
|
+
if not existing:
|
|
479
|
+
return ["New configuration created"]
|
|
480
|
+
|
|
481
|
+
changes = []
|
|
482
|
+
|
|
483
|
+
# 框架变化
|
|
484
|
+
if existing.get('framework') != detection.get('framework'):
|
|
485
|
+
changes.append(f"Framework: {existing.get('framework', 'None')} → {detection.get('framework')}")
|
|
486
|
+
|
|
487
|
+
# 构建命令变化
|
|
488
|
+
old_build = existing.get('build', {}).get('command', '')
|
|
489
|
+
new_build = detection.get('build', {}).get('command', '')
|
|
490
|
+
if old_build != new_build:
|
|
491
|
+
changes.append(f"Build command: {old_build or 'None'} → {new_build or 'None'}")
|
|
492
|
+
|
|
493
|
+
# 启动命令变化
|
|
494
|
+
old_runtime = existing.get('runtime', {}).get('command', '')
|
|
495
|
+
new_runtime = detection.get('runtime', {}).get('command', '')
|
|
496
|
+
if old_runtime != new_runtime:
|
|
497
|
+
changes.append(f"Start command: {old_runtime} → {new_runtime}")
|
|
498
|
+
|
|
499
|
+
# 端口变化
|
|
500
|
+
old_port = existing.get('runtime', {}).get('port')
|
|
501
|
+
new_port = detection.get('runtime', {}).get('port')
|
|
502
|
+
if old_port != new_port:
|
|
503
|
+
changes.append(f"Port: {old_port} → {new_port}")
|
|
504
|
+
|
|
505
|
+
# 卷变化
|
|
506
|
+
if _volumes_changed(existing.get('volumes', []), detection.get('volumes', [])):
|
|
507
|
+
old_count = len(existing.get('volumes', []))
|
|
508
|
+
new_count = len(detection.get('volumes', []))
|
|
509
|
+
changes.append(f"Volumes: {old_count} → {new_count} (re-detect data directories)")
|
|
510
|
+
|
|
511
|
+
return changes
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def update_app_config(project_path: str, app_name: Optional[str] = None,
|
|
515
|
+
app_version: Optional[str] = None) -> Dict:
|
|
516
|
+
"""
|
|
517
|
+
更新配置中的应用名称和版本(仅更新这两个字段,保留其他配置)
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
project_path: 项目路径
|
|
521
|
+
app_name: 应用名称(可选)
|
|
522
|
+
app_version: 应用版本(可选)
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
更新结果
|
|
526
|
+
"""
|
|
527
|
+
try:
|
|
528
|
+
# 读取现有配置
|
|
529
|
+
config = read_config(project_path)
|
|
530
|
+
|
|
531
|
+
if not config:
|
|
532
|
+
return {
|
|
533
|
+
'success': False,
|
|
534
|
+
'error': 'CONFIG_NOT_FOUND',
|
|
535
|
+
'message': 'app-deploy.json not found',
|
|
536
|
+
'suggestion': 'Run detect_project first to generate app-deploy.json'
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
# 记录原始值用于比对
|
|
540
|
+
original_app_name = config.get('appName', '')
|
|
541
|
+
original_app_version = config.get('appVersion', '1.0.0')
|
|
542
|
+
|
|
543
|
+
# 首次打包:如果配置中没有 appName,必须提供
|
|
544
|
+
if not config.get('appName'):
|
|
545
|
+
if not app_name:
|
|
546
|
+
return {
|
|
547
|
+
'success': False,
|
|
548
|
+
'error': 'APP_NAME_REQUIRED',
|
|
549
|
+
'message': 'This is the first time packaging this project. Please provide app_name parameter.',
|
|
550
|
+
'instruction': 'The MCP client should prompt user to confirm the application name.'
|
|
551
|
+
}
|
|
552
|
+
# 首次设置 appName
|
|
553
|
+
config['appName'] = app_name
|
|
554
|
+
else:
|
|
555
|
+
# 如果提供了新的 appName,使用新值(允许用户修改应用名)
|
|
556
|
+
if app_name:
|
|
557
|
+
config['appName'] = app_name
|
|
558
|
+
|
|
559
|
+
# 每次打包:必须提供 appVersion
|
|
560
|
+
if not app_version:
|
|
561
|
+
return {
|
|
562
|
+
'success': False,
|
|
563
|
+
'error': 'APP_VERSION_REQUIRED',
|
|
564
|
+
'message': 'Please provide app_version parameter for this package.',
|
|
565
|
+
'instruction': 'The MCP client should prompt user to confirm the version number.',
|
|
566
|
+
'current_version': config.get('appVersion', '1.0.0'),
|
|
567
|
+
'suggestion': f'Suggested next version: {_suggest_next_version(config.get("appVersion", "1.0.0"))}'
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
# 更新 appVersion
|
|
571
|
+
config['appVersion'] = app_version
|
|
572
|
+
|
|
573
|
+
# 比对是否有变化
|
|
574
|
+
has_changes = (
|
|
575
|
+
config['appName'] != original_app_name or
|
|
576
|
+
config['appVersion'] != original_app_version
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# 只在有变化时写回配置文件
|
|
580
|
+
if has_changes:
|
|
581
|
+
write_config(project_path, config)
|
|
582
|
+
else:
|
|
583
|
+
# 无变化,跳过写入
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
'success': True,
|
|
588
|
+
'app_name': config['appName'],
|
|
589
|
+
'app_version': config['appVersion'],
|
|
590
|
+
'operation': 'updated' if has_changes else 'unchanged',
|
|
591
|
+
'message': 'Configuration updated successfully' if has_changes else 'Configuration unchanged, skip writing'
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
except ValueError as e:
|
|
595
|
+
return {
|
|
596
|
+
'success': False,
|
|
597
|
+
'error': 'CONFIG_ERROR',
|
|
598
|
+
'message': str(e)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _suggest_next_version(current_version: str) -> str:
|
|
603
|
+
"""
|
|
604
|
+
建议下一个版本号
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
current_version: 当前版本号
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
建议的版本号
|
|
611
|
+
"""
|
|
612
|
+
try:
|
|
613
|
+
parts = current_version.split('.')
|
|
614
|
+
if len(parts) == 3:
|
|
615
|
+
# 语义化版本:递增补丁版本号
|
|
616
|
+
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
|
|
617
|
+
elif len(parts) == 2:
|
|
618
|
+
# 主.次版本:递增次版本号
|
|
619
|
+
return f"{parts[0]}.{int(parts[1]) + 1}"
|
|
620
|
+
else:
|
|
621
|
+
# 其他格式:直接递增
|
|
622
|
+
if current_version.isdigit():
|
|
623
|
+
return str(int(current_version) + 1)
|
|
624
|
+
else:
|
|
625
|
+
return f"{current_version}-2"
|
|
626
|
+
except (ValueError, IndexError):
|
|
627
|
+
# 解析失败,使用默认格式
|
|
628
|
+
return "1.0.1"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# 向后兼容的导出(供现有代码使用)
|
|
632
|
+
# from manage_config import manage_config
|