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,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