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,543 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Project Packager - 项目打包器
|
|
4
|
+
|
|
5
|
+
排除依赖和构建产物,将项目压缩为 tar.gz 格式
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Set
|
|
18
|
+
|
|
19
|
+
from .config_utils import update_app_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# 默认排除规则
|
|
23
|
+
DEFAULT_EXCLUDE_PATTERNS = [
|
|
24
|
+
# Node.js 排除规则
|
|
25
|
+
'node_modules',
|
|
26
|
+
'.git',
|
|
27
|
+
'.next',
|
|
28
|
+
'.nuxt',
|
|
29
|
+
'dist',
|
|
30
|
+
'build',
|
|
31
|
+
'out',
|
|
32
|
+
'.turbo',
|
|
33
|
+
'.cache',
|
|
34
|
+
'coverage',
|
|
35
|
+
'.vscode',
|
|
36
|
+
'.idea',
|
|
37
|
+
'.claude',
|
|
38
|
+
'CLAUDE.md',
|
|
39
|
+
'.esdata',
|
|
40
|
+
'.nx',
|
|
41
|
+
'.lerna',
|
|
42
|
+
'.parcel-cache',
|
|
43
|
+
'.vite',
|
|
44
|
+
'.DS_Store',
|
|
45
|
+
'Thumbs.db',
|
|
46
|
+
'*.log',
|
|
47
|
+
'.env.local',
|
|
48
|
+
'.env.*.local',
|
|
49
|
+
|
|
50
|
+
# Python 排除规则
|
|
51
|
+
'__pycache__',
|
|
52
|
+
'*.pyc',
|
|
53
|
+
'*.pyo',
|
|
54
|
+
'*.pyd',
|
|
55
|
+
'.Python',
|
|
56
|
+
'.venv',
|
|
57
|
+
'venv',
|
|
58
|
+
'env',
|
|
59
|
+
'ENV',
|
|
60
|
+
'.pytest_cache',
|
|
61
|
+
'.mypy_cache',
|
|
62
|
+
'.ruff_cache',
|
|
63
|
+
'*.egg-info',
|
|
64
|
+
'*.egg',
|
|
65
|
+
'.tox',
|
|
66
|
+
'.coverage',
|
|
67
|
+
'htmlcov',
|
|
68
|
+
|
|
69
|
+
# 数据持久化和临时文件目录
|
|
70
|
+
# 这些目录通常应该通过卷挂载,而不是打包到镜像中
|
|
71
|
+
'tmp',
|
|
72
|
+
'temp',
|
|
73
|
+
'.tmp',
|
|
74
|
+
'log',
|
|
75
|
+
'logs',
|
|
76
|
+
'upload',
|
|
77
|
+
'uploads',
|
|
78
|
+
'backup',
|
|
79
|
+
'backups',
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ProjectPackager:
|
|
84
|
+
def __init__(self, project_path: str, output_path: str, app_name: str = None, app_version: str = None):
|
|
85
|
+
self.project_path = Path(project_path).resolve()
|
|
86
|
+
self.output_path = Path(output_path).resolve()
|
|
87
|
+
self.exclude_patterns = DEFAULT_EXCLUDE_PATTERNS.copy()
|
|
88
|
+
self.excluded_count = 0
|
|
89
|
+
self.included_count = 0
|
|
90
|
+
self.app_name = app_name
|
|
91
|
+
self.app_version = app_version
|
|
92
|
+
|
|
93
|
+
def _sanitize_name(self, name: str) -> str:
|
|
94
|
+
"""清理名称用于文件名"""
|
|
95
|
+
if not name:
|
|
96
|
+
return "app"
|
|
97
|
+
|
|
98
|
+
# 只允许字母、数字、下划线、点号和连字符
|
|
99
|
+
# 替换空格为连字符
|
|
100
|
+
sanitized = re.sub(r'[^-~0-9A-Za-z._-]', '-', name) # 只允许可见ASCII字符
|
|
101
|
+
sanitized = re.sub(r'[<>]', '-', sanitized) # 尖括号转连字符
|
|
102
|
+
sanitized = re.sub(r'-+', '-', sanitized) # 多个连字符视为一个
|
|
103
|
+
sanitized = sanitized.strip('-').strip('.') # 去除首尾连字符和点
|
|
104
|
+
|
|
105
|
+
# 长度限制,确保整个文件名不超过255字符(文件系统限制)
|
|
106
|
+
max_length = 50 # 为版本号预留空间
|
|
107
|
+
return sanitized[:max_length] if sanitized else "app"
|
|
108
|
+
|
|
109
|
+
def _generate_friendly_filename(self, output_path: str) -> str:
|
|
110
|
+
"""生成友好的文件名:{app_name}-v{app_version}.tar.gz"""
|
|
111
|
+
if output_path and output_path.strip(): # 如果用户指定了路径,使用用户的
|
|
112
|
+
return output_path.strip()
|
|
113
|
+
|
|
114
|
+
# 使用 {app_name}-v{app_version} 格式
|
|
115
|
+
clean_app_name = self._sanitize_name(self.app_name or "app")
|
|
116
|
+
clean_version = self._sanitize_name(self.app_version or "1.0.0")
|
|
117
|
+
|
|
118
|
+
filename = f"{clean_app_name}-v{clean_version}"
|
|
119
|
+
|
|
120
|
+
# 确保扩展名
|
|
121
|
+
if not filename.endswith(('.tar.gz', '.tgz')):
|
|
122
|
+
filename += '.tar.gz'
|
|
123
|
+
|
|
124
|
+
return filename
|
|
125
|
+
|
|
126
|
+
def package(self) -> dict:
|
|
127
|
+
"""执行项目打包"""
|
|
128
|
+
# 验证项目路径
|
|
129
|
+
if not self.project_path.exists():
|
|
130
|
+
return {
|
|
131
|
+
'success': False,
|
|
132
|
+
'error': f'Project path does not exist: {self.project_path}'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# 检查项目类型
|
|
136
|
+
has_package_json = (self.project_path / 'package.json').exists()
|
|
137
|
+
has_requirements_txt = (self.project_path / 'requirements.txt').exists()
|
|
138
|
+
|
|
139
|
+
if not has_package_json and not has_requirements_txt:
|
|
140
|
+
return {
|
|
141
|
+
'success': False,
|
|
142
|
+
'error': 'No package.json or requirements.txt found. Not a valid Node.js or Python project.'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# 读取并更新 app-deploy.json
|
|
146
|
+
config_result = update_app_config(
|
|
147
|
+
str(self.project_path),
|
|
148
|
+
app_name=self.app_name,
|
|
149
|
+
app_version=self.app_version
|
|
150
|
+
)
|
|
151
|
+
if not config_result['success']:
|
|
152
|
+
return config_result
|
|
153
|
+
|
|
154
|
+
# 更新实例变量
|
|
155
|
+
self.app_name = config_result['app_name']
|
|
156
|
+
self.app_version = config_result['app_version']
|
|
157
|
+
|
|
158
|
+
# ========== Node.js 构建检查 ==========
|
|
159
|
+
if has_package_json:
|
|
160
|
+
build_check = self._check_nodejs_build()
|
|
161
|
+
if not build_check['success']:
|
|
162
|
+
return build_check
|
|
163
|
+
|
|
164
|
+
# 读取 .dockerignore(如果存在)
|
|
165
|
+
self._load_dockerignore()
|
|
166
|
+
|
|
167
|
+
# 读取 .gitignore(如果存在且没有 .dockerignore)
|
|
168
|
+
self._load_gitignore()
|
|
169
|
+
|
|
170
|
+
# 读取子目录的 ignore 文件(hybrid 项目)
|
|
171
|
+
self._load_subdir_ignore_files()
|
|
172
|
+
|
|
173
|
+
# 读取卷配置,自动排除卷目录
|
|
174
|
+
self._load_volume_excludes()
|
|
175
|
+
|
|
176
|
+
# 创建输出目录
|
|
177
|
+
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
# 打包项目
|
|
180
|
+
try:
|
|
181
|
+
with tarfile.open(self.output_path, 'w:gz') as tar:
|
|
182
|
+
self._add_directory_to_tar(tar, self.project_path, '')
|
|
183
|
+
|
|
184
|
+
# 获取文件大小
|
|
185
|
+
size_bytes = self.output_path.stat().st_size
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
'success': True,
|
|
189
|
+
'output_path': str(self.output_path),
|
|
190
|
+
'size': self._format_size(size_bytes),
|
|
191
|
+
'size_bytes': size_bytes,
|
|
192
|
+
'app_name': self.app_name,
|
|
193
|
+
'app_version': self.app_version,
|
|
194
|
+
'excluded_patterns': len(self.exclude_patterns),
|
|
195
|
+
'included_files': self.included_count,
|
|
196
|
+
'excluded_files': self.excluded_count,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return {
|
|
201
|
+
'success': False,
|
|
202
|
+
'error': f'Failed to package project: {str(e)}'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _suggest_next_version(self, current_version: str) -> str:
|
|
207
|
+
"""建议下一个版本号"""
|
|
208
|
+
try:
|
|
209
|
+
parts = current_version.split('.')
|
|
210
|
+
if len(parts) == 3:
|
|
211
|
+
major, minor, patch = parts
|
|
212
|
+
# 递增 patch 版本
|
|
213
|
+
next_patch = int(patch) + 1
|
|
214
|
+
return f"{major}.{minor}.{next_patch}"
|
|
215
|
+
except:
|
|
216
|
+
pass
|
|
217
|
+
return "1.0.1"
|
|
218
|
+
|
|
219
|
+
def _check_nodejs_build(self) -> dict:
|
|
220
|
+
"""检查 Node.js 项目是否已构建"""
|
|
221
|
+
# 读取 app-deploy.json 获取框架信息
|
|
222
|
+
config_path = self.project_path / 'app-deploy.json'
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
226
|
+
config = json.load(f)
|
|
227
|
+
except Exception:
|
|
228
|
+
# 如果读取失败,跳过检查
|
|
229
|
+
return {'success': True}
|
|
230
|
+
|
|
231
|
+
framework = config.get('framework', '').lower()
|
|
232
|
+
|
|
233
|
+
# 需要构建的框架
|
|
234
|
+
build_required_frameworks = {
|
|
235
|
+
'nextjs': ['.next'],
|
|
236
|
+
'nuxt': ['.nuxt', '.output'],
|
|
237
|
+
'vite': ['dist'],
|
|
238
|
+
'vite-react': ['dist'],
|
|
239
|
+
'vite-vue': ['dist'],
|
|
240
|
+
'remix': ['build', 'public/build'],
|
|
241
|
+
'astro': ['dist'],
|
|
242
|
+
'sveltekit': ['.svelte-kit', 'build'],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# 检查是否需要构建
|
|
246
|
+
if framework not in build_required_frameworks:
|
|
247
|
+
# 不需要构建的框架(如 express, fastify)
|
|
248
|
+
return {'success': True}
|
|
249
|
+
|
|
250
|
+
# 获取构建输出目录列表
|
|
251
|
+
build_dirs = build_required_frameworks[framework]
|
|
252
|
+
|
|
253
|
+
# 检查是否存在构建输出
|
|
254
|
+
found_build = False
|
|
255
|
+
for build_dir in build_dirs:
|
|
256
|
+
build_path = self.project_path / build_dir
|
|
257
|
+
if build_path.exists():
|
|
258
|
+
found_build = True
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if not found_build:
|
|
262
|
+
# 获取构建命令
|
|
263
|
+
build_command = config.get('build', {}).get('command', 'npm run build')
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
'success': False,
|
|
267
|
+
'error': 'BUILD_REQUIRED',
|
|
268
|
+
'message': f'Project needs to be built before packaging.',
|
|
269
|
+
'details': {
|
|
270
|
+
'framework': framework,
|
|
271
|
+
'missing_dirs': build_dirs,
|
|
272
|
+
'build_command': build_command,
|
|
273
|
+
'instruction': f'Please run "{build_command}" first, then try packaging again.'
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {'success': True}
|
|
278
|
+
|
|
279
|
+
def _load_dockerignore(self):
|
|
280
|
+
"""读取 .dockerignore 文件"""
|
|
281
|
+
dockerignore_path = self.project_path / '.dockerignore'
|
|
282
|
+
if not dockerignore_path.exists():
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
with open(dockerignore_path, 'r', encoding='utf-8') as f:
|
|
287
|
+
for line in f:
|
|
288
|
+
line = line.strip()
|
|
289
|
+
# 跳过空行和注释
|
|
290
|
+
if line and not line.startswith('#'):
|
|
291
|
+
# 移除前导的 /
|
|
292
|
+
pattern = line.lstrip('/')
|
|
293
|
+
if pattern not in self.exclude_patterns:
|
|
294
|
+
self.exclude_patterns.append(pattern)
|
|
295
|
+
except Exception:
|
|
296
|
+
pass # 忽略读取错误
|
|
297
|
+
|
|
298
|
+
def _load_gitignore(self):
|
|
299
|
+
"""读取 .gitignore 文件(如果没有 .dockerignore)"""
|
|
300
|
+
# 如果已经有 .dockerignore,优先使用它
|
|
301
|
+
dockerignore_path = self.project_path / '.dockerignore'
|
|
302
|
+
if dockerignore_path.exists():
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
gitignore_path = self.project_path / '.gitignore'
|
|
306
|
+
if not gitignore_path.exists():
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
with open(gitignore_path, 'r', encoding='utf-8') as f:
|
|
311
|
+
for line in f:
|
|
312
|
+
line = line.strip()
|
|
313
|
+
# 跳过空行和注释
|
|
314
|
+
if line and not line.startswith('#'):
|
|
315
|
+
# 移除前导的 /
|
|
316
|
+
pattern = line.lstrip('/')
|
|
317
|
+
if pattern not in self.exclude_patterns:
|
|
318
|
+
self.exclude_patterns.append(pattern)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass # 忽略读取错误
|
|
321
|
+
|
|
322
|
+
def _load_subdir_ignore_files(self):
|
|
323
|
+
"""读取子目录的 ignore 文件(hybrid 项目支持)"""
|
|
324
|
+
config_path = self.project_path / 'app-deploy.json'
|
|
325
|
+
if not config_path.exists():
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
330
|
+
config = json.load(f)
|
|
331
|
+
|
|
332
|
+
# 仅 hybrid 项目需要扫描子目录
|
|
333
|
+
if config.get('framework') != 'hybrid':
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
components = config.get('components', [])
|
|
337
|
+
for comp in components:
|
|
338
|
+
comp_path = comp.get('path', '.')
|
|
339
|
+
if comp_path == '.':
|
|
340
|
+
continue # 根目录已处理
|
|
341
|
+
|
|
342
|
+
subdir = self.project_path / comp_path.lstrip('./')
|
|
343
|
+
if not subdir.exists():
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# 读取子目录的 .dockerignore
|
|
347
|
+
subdir_dockerignore = subdir / '.dockerignore'
|
|
348
|
+
if subdir_dockerignore.exists():
|
|
349
|
+
self._load_ignore_file(subdir_dockerignore, comp_path)
|
|
350
|
+
else:
|
|
351
|
+
# 或读取 .gitignore
|
|
352
|
+
subdir_gitignore = subdir / '.gitignore'
|
|
353
|
+
if subdir_gitignore.exists():
|
|
354
|
+
self._load_ignore_file(subdir_gitignore, comp_path)
|
|
355
|
+
|
|
356
|
+
except Exception:
|
|
357
|
+
pass # 忽略读取错误
|
|
358
|
+
|
|
359
|
+
def _load_ignore_file(self, file_path: Path, prefix: str):
|
|
360
|
+
"""读取 ignore 文件并添加路径前缀"""
|
|
361
|
+
try:
|
|
362
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
363
|
+
for line in f:
|
|
364
|
+
line = line.strip()
|
|
365
|
+
# 跳过空行和注释
|
|
366
|
+
if line and not line.startswith('#'):
|
|
367
|
+
# 移除前导的 /
|
|
368
|
+
pattern = line.lstrip('/')
|
|
369
|
+
# 添加子目录前缀
|
|
370
|
+
full_pattern = f"{prefix.lstrip('./')}/{pattern}"
|
|
371
|
+
if full_pattern not in self.exclude_patterns:
|
|
372
|
+
self.exclude_patterns.append(full_pattern)
|
|
373
|
+
except Exception:
|
|
374
|
+
pass # 忽略读取错误
|
|
375
|
+
|
|
376
|
+
def _load_volume_excludes(self):
|
|
377
|
+
"""从 app-deploy.json 读取卷配置,自动排除卷目录"""
|
|
378
|
+
config_path = self.project_path / 'app-deploy.json'
|
|
379
|
+
if not config_path.exists():
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
384
|
+
config = json.load(f)
|
|
385
|
+
|
|
386
|
+
volumes = config.get('volumes', [])
|
|
387
|
+
if not volumes:
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# 自动排除所有卷目录
|
|
391
|
+
for volume in volumes:
|
|
392
|
+
source = volume.get('source', '')
|
|
393
|
+
if not source:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
# 标准化路径:移除前导 ./ 或 /
|
|
397
|
+
normalized_source = source.lstrip('./').lstrip('/')
|
|
398
|
+
|
|
399
|
+
# 避免重复添加
|
|
400
|
+
if normalized_source and normalized_source not in self.exclude_patterns:
|
|
401
|
+
self.exclude_patterns.append(normalized_source)
|
|
402
|
+
|
|
403
|
+
except Exception:
|
|
404
|
+
pass # 忽略读取错误
|
|
405
|
+
|
|
406
|
+
def _add_directory_to_tar(self, tar: tarfile.TarFile, directory: Path, arcname_prefix: str):
|
|
407
|
+
"""递归添加目录到 tar 包"""
|
|
408
|
+
try:
|
|
409
|
+
for item in directory.iterdir():
|
|
410
|
+
# 计算相对路径
|
|
411
|
+
relative_path = item.relative_to(self.project_path)
|
|
412
|
+
arcname = str(relative_path)
|
|
413
|
+
|
|
414
|
+
# 检查是否应该排除
|
|
415
|
+
if self._should_exclude(str(relative_path)):
|
|
416
|
+
self.excluded_count += 1
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# 添加文件或目录
|
|
420
|
+
if item.is_file():
|
|
421
|
+
tar.add(item, arcname=arcname, recursive=False)
|
|
422
|
+
self.included_count += 1
|
|
423
|
+
elif item.is_dir():
|
|
424
|
+
# 递归添加子目录
|
|
425
|
+
self._add_directory_to_tar(tar, item, arcname)
|
|
426
|
+
|
|
427
|
+
except PermissionError:
|
|
428
|
+
pass # 跳过没有权限的目录
|
|
429
|
+
|
|
430
|
+
def _should_exclude(self, path: str) -> bool:
|
|
431
|
+
"""检查路径是否应该被排除"""
|
|
432
|
+
path_parts = Path(path).parts
|
|
433
|
+
|
|
434
|
+
for pattern in self.exclude_patterns:
|
|
435
|
+
# 检查是否匹配排除模式
|
|
436
|
+
if self._match_exclude_pattern(path, path_parts, pattern):
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
def _match_exclude_pattern(self, path: str, path_parts: tuple, pattern: str) -> bool:
|
|
442
|
+
"""匹配排除模式"""
|
|
443
|
+
# 精确匹配整个路径
|
|
444
|
+
if path == pattern:
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
# 匹配路径中的任何部分
|
|
448
|
+
if pattern in path_parts:
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
# 通配符匹配(*.log)
|
|
452
|
+
if '*' in pattern:
|
|
453
|
+
if pattern.startswith('*.'):
|
|
454
|
+
# 文件扩展名匹配
|
|
455
|
+
ext = pattern[1:] # 移除 *
|
|
456
|
+
if path.endswith(ext):
|
|
457
|
+
return True
|
|
458
|
+
elif pattern.endswith('*'):
|
|
459
|
+
# 前缀匹配
|
|
460
|
+
prefix = pattern[:-1]
|
|
461
|
+
if any(part.startswith(prefix) for part in path_parts):
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
# 路径前缀匹配
|
|
465
|
+
if path.startswith(pattern + '/') or path.startswith(pattern + os.sep):
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
def _format_size(self, size_bytes: int) -> str:
|
|
471
|
+
"""格式化文件大小"""
|
|
472
|
+
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
473
|
+
if size_bytes < 1024:
|
|
474
|
+
return f"{size_bytes:.2f} {unit}"
|
|
475
|
+
size_bytes /= 1024
|
|
476
|
+
return f"{size_bytes:.2f} TB"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def main():
|
|
480
|
+
if len(sys.argv) < 2:
|
|
481
|
+
print(json.dumps({
|
|
482
|
+
'success': False,
|
|
483
|
+
'error': 'Usage: pack_project.py <project_path> [output_path] [app_name] [app_version]',
|
|
484
|
+
'description': {
|
|
485
|
+
'project_path': 'Project directory to package (required)',
|
|
486
|
+
'output_path': 'Output tar.gz path (optional, auto-generated if omitted or empty)',
|
|
487
|
+
'app_name': 'Required for first-time packaging, then stored in app-deploy.json',
|
|
488
|
+
'app_version': 'Required for each packaging'
|
|
489
|
+
},
|
|
490
|
+
'examples': [
|
|
491
|
+
'pack_project.py . "" myapp 1.0.0 # Auto-generate output path',
|
|
492
|
+
'pack_project.py . /tmp/myapp.tar.gz myapp 1.0.0 # Manual path'
|
|
493
|
+
]
|
|
494
|
+
}, indent=2))
|
|
495
|
+
sys.exit(1)
|
|
496
|
+
|
|
497
|
+
project_path = sys.argv[1]
|
|
498
|
+
|
|
499
|
+
# ========== 跨平台临时路径自动生成 ==========
|
|
500
|
+
# 如果 output_path 未提供或为空,使用友好的文件名格式
|
|
501
|
+
if len(sys.argv) < 3 or not sys.argv[2]:
|
|
502
|
+
temp_dir = tempfile.gettempdir()
|
|
503
|
+
# 生成友好的文件名: {app_name}-v{app_version}.tar.gz
|
|
504
|
+
friendly_filename = ""
|
|
505
|
+
if len(sys.argv) > 3 and sys.argv[3]: # app_name 存在
|
|
506
|
+
app_name = sys.argv[3]
|
|
507
|
+
# 清理应用名称(只允许字母数字连字符下划线点)
|
|
508
|
+
clean_name = re.sub(r'[^a-zA-Z0-9._-]', '-', app_name)
|
|
509
|
+
clean_name = re.sub(r'-+', '-', clean_name).strip('-').strip('.')
|
|
510
|
+
friendly_filename = clean_name[:50] # 长度限制
|
|
511
|
+
|
|
512
|
+
if len(sys.argv) > 4 and sys.argv[4]: # app_version 存在
|
|
513
|
+
app_version = sys.argv[4]
|
|
514
|
+
friendly_filename += f"-v{app_version}"
|
|
515
|
+
|
|
516
|
+
if not friendly_filename: # 退化回时间戳
|
|
517
|
+
timestamp = int(time.time())
|
|
518
|
+
friendly_filename = f"deployment-{timestamp}"
|
|
519
|
+
|
|
520
|
+
output_path = os.path.join(temp_dir, f"{friendly_filename}.tar.gz")
|
|
521
|
+
else:
|
|
522
|
+
output_path = sys.argv[2]
|
|
523
|
+
|
|
524
|
+
app_name = sys.argv[3] if len(sys.argv) > 3 else None
|
|
525
|
+
app_version = sys.argv[4] if len(sys.argv) > 4 else None
|
|
526
|
+
|
|
527
|
+
# 如果输出路径没有扩展名,添加 .tar.gz
|
|
528
|
+
if not output_path.endswith('.tar.gz') and not output_path.endswith('.tgz'):
|
|
529
|
+
output_path = output_path + '.tar.gz'
|
|
530
|
+
|
|
531
|
+
# 执行打包
|
|
532
|
+
packager = ProjectPackager(project_path, output_path, app_name, app_version)
|
|
533
|
+
result = packager.package()
|
|
534
|
+
|
|
535
|
+
# 输出 JSON 结果
|
|
536
|
+
print(json.dumps(result, indent=2))
|
|
537
|
+
|
|
538
|
+
if not result.get('success', False):
|
|
539
|
+
sys.exit(1)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
if __name__ == '__main__':
|
|
543
|
+
main()
|
mobox/main.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
app = typer.Typer(
|
|
4
|
+
name="mobox",
|
|
5
|
+
help="Mobox deployment CLI - detect, pack, deploy and manage applications",
|
|
6
|
+
no_args_is_help=True,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
# Config subcommand group
|
|
10
|
+
config_app = typer.Typer(name="config", help="Manage local configuration")
|
|
11
|
+
app.add_typer(config_app)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.callback(invoke_without_command=True)
|
|
15
|
+
def main(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
18
|
+
):
|
|
19
|
+
if version:
|
|
20
|
+
from mobox import __version__
|
|
21
|
+
|
|
22
|
+
typer.echo(f"mobox {__version__}")
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- Config subcommands ---
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@config_app.command("get")
|
|
30
|
+
def config_get(key: str = typer.Argument(..., help="Config key")):
|
|
31
|
+
"""Get a config value"""
|
|
32
|
+
from mobox import utils
|
|
33
|
+
from mobox.config import get_config
|
|
34
|
+
|
|
35
|
+
val = get_config(key)
|
|
36
|
+
if val is None:
|
|
37
|
+
utils.error(f"Key '{key}' not found")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
utils.console.print(f"{key} = {val}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@config_app.command("set")
|
|
43
|
+
def config_set(
|
|
44
|
+
key: str = typer.Argument(..., help="Config key"),
|
|
45
|
+
value: str = typer.Argument(..., help="Config value"),
|
|
46
|
+
):
|
|
47
|
+
"""Set a config value"""
|
|
48
|
+
from mobox import utils
|
|
49
|
+
from mobox.config import set_config
|
|
50
|
+
|
|
51
|
+
set_config(key, value)
|
|
52
|
+
utils.success(f"{key} = {value}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@config_app.command("list")
|
|
56
|
+
def config_list():
|
|
57
|
+
"""List all config values"""
|
|
58
|
+
from mobox import utils
|
|
59
|
+
from mobox.config import list_config
|
|
60
|
+
|
|
61
|
+
cfg = list_config()
|
|
62
|
+
if not cfg:
|
|
63
|
+
utils.info("No configuration set.")
|
|
64
|
+
return
|
|
65
|
+
for k, v in cfg.items():
|
|
66
|
+
utils.console.print(f" {k} = {v}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- Register all commands ---
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _register_commands():
|
|
73
|
+
from mobox.auth import login, logout, whoami
|
|
74
|
+
|
|
75
|
+
app.command()(login)
|
|
76
|
+
app.command()(logout)
|
|
77
|
+
app.command()(whoami)
|
|
78
|
+
|
|
79
|
+
from mobox.commands.manage import apps, control, delete, status, boxes, images, templates
|
|
80
|
+
|
|
81
|
+
app.command()(apps)
|
|
82
|
+
app.command()(status)
|
|
83
|
+
app.command()(control)
|
|
84
|
+
app.command()(delete)
|
|
85
|
+
app.command()(boxes)
|
|
86
|
+
app.command()(images)
|
|
87
|
+
app.command()(templates)
|
|
88
|
+
|
|
89
|
+
from mobox.commands.local import detect, pack
|
|
90
|
+
|
|
91
|
+
app.command()(detect)
|
|
92
|
+
app.command()(pack)
|
|
93
|
+
|
|
94
|
+
from mobox.commands.deploy import deploy, upload
|
|
95
|
+
|
|
96
|
+
app.command()(upload)
|
|
97
|
+
app.command()(deploy)
|
|
98
|
+
|
|
99
|
+
from mobox.commands.ops import bash, file, logs
|
|
100
|
+
|
|
101
|
+
app.command()(logs)
|
|
102
|
+
app.command()(bash)
|
|
103
|
+
app.command(name="file")(file)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_register_commands()
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
app()
|
mobox/utils.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Rich output helpers for mobox CLI"""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
err_console = Console(stderr=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def success(msg: str):
|
|
11
|
+
console.print(f"[green]\u2713[/green] {msg}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def error(msg: str):
|
|
15
|
+
err_console.print(f"[red]\u2717[/red] {msg}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def info(msg: str):
|
|
19
|
+
console.print(f"[blue]\u2139[/blue] {msg}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def warn(msg: str):
|
|
23
|
+
console.print(f"[yellow]\u26a0[/yellow] {msg}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def waiting(msg: str):
|
|
27
|
+
console.print(f"[dim]\u23f3[/dim] {msg}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_table(
|
|
31
|
+
title: str, columns: list[tuple[str, str]], rows: list[list[str]]
|
|
32
|
+
) -> Table:
|
|
33
|
+
"""Build a Rich table.
|
|
34
|
+
columns: list of (header, style) tuples
|
|
35
|
+
"""
|
|
36
|
+
table = Table(title=title, show_header=True, header_style="bold")
|
|
37
|
+
for header, style in columns:
|
|
38
|
+
table.add_column(header, style=style)
|
|
39
|
+
for row in rows:
|
|
40
|
+
table.add_row(*row)
|
|
41
|
+
return table
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_auth():
|
|
45
|
+
"""Load credentials or exit with login hint"""
|
|
46
|
+
from mobox.config import load_credentials
|
|
47
|
+
|
|
48
|
+
creds = load_credentials()
|
|
49
|
+
if not creds or not creds.get("mcp_key"):
|
|
50
|
+
error("Not logged in. Run [bold]mobox login[/bold] first.")
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
return creds
|