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