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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
mobox/auth.py ADDED
@@ -0,0 +1,254 @@
1
+ """Authentication commands: login, logout, whoami"""
2
+
3
+ import asyncio
4
+ import json
5
+ import random
6
+ import re
7
+ import secrets
8
+ import time
9
+ import webbrowser
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from urllib.parse import parse_qs, quote, urlparse
12
+
13
+ import typer
14
+ from rich.prompt import Prompt
15
+
16
+ from mobox import utils
17
+ from mobox.config import (
18
+ DEFAULT_SERVER,
19
+ clear_credentials,
20
+ load_credentials,
21
+ save_credentials,
22
+ )
23
+
24
+ _I18N = {
25
+ "zh": {
26
+ "success_title": "\u2705 登录成功!",
27
+ "success_body": "你可以关闭此页面,返回终端继续操作。",
28
+ "error_state": "状态不匹配(CSRF 校验失败)",
29
+ "error_missing_key": "回调缺少密钥(mcp_key)",
30
+ },
31
+ "en": {
32
+ "success_title": "\u2705 Login successful!",
33
+ "success_body": "You can close this page and return to terminal.",
34
+ "error_state": "State mismatch (CSRF protection)",
35
+ "error_missing_key": "Missing mcp_key in callback",
36
+ },
37
+ }
38
+
39
+
40
+ def _detect_lang(accept_language: str | None) -> str:
41
+ if not accept_language:
42
+ return "en"
43
+ for part in re.split(r",\s*", accept_language):
44
+ tag = part.split(";")[0].strip().lower()
45
+ if tag.startswith("zh"):
46
+ return "zh"
47
+ if tag.startswith("en"):
48
+ return "en"
49
+ return "en"
50
+
51
+
52
+ def _make_callback_handler(state: str, login_result: dict | None = None, server: str = ""):
53
+ if login_result is None:
54
+ login_result = {"received": False, "error": None}
55
+
56
+ class CallbackHandler(BaseHTTPRequestHandler):
57
+ def do_GET(self):
58
+ parsed = urlparse(self.path)
59
+ if parsed.path != "/callback":
60
+ self.send_response(404)
61
+ self.end_headers()
62
+ return
63
+
64
+ lang = _detect_lang(self.headers.get("Accept-Language"))
65
+ t = _I18N[lang]
66
+ params = parse_qs(parsed.query)
67
+
68
+ received_state = params.get("state", [""])[0]
69
+ if received_state != state:
70
+ self.send_response(400)
71
+ self.send_header("Content-Type", "text/html; charset=utf-8")
72
+ self.end_headers()
73
+ self.wfile.write(t["error_state"].encode("utf-8"))
74
+ login_result["error"] = "State mismatch (CSRF protection)"
75
+ return
76
+
77
+ mcp_key = params.get("mcp_key", [""])[0]
78
+ namespace = params.get("namespace", [""])[0]
79
+
80
+ if not mcp_key:
81
+ self.send_response(400)
82
+ self.send_header("Content-Type", "text/html; charset=utf-8")
83
+ self.end_headers()
84
+ self.wfile.write(t["error_missing_key"].encode("utf-8"))
85
+ login_result["error"] = "Missing mcp_key in callback"
86
+ return
87
+
88
+ login_result["mcp_key"] = mcp_key
89
+ login_result["namespace"] = namespace
90
+ login_result["server_url"] = server or DEFAULT_SERVER
91
+ login_result["received"] = True
92
+
93
+ self.send_response(200)
94
+ self.send_header("Content-Type", "text/html; charset=utf-8")
95
+ self.end_headers()
96
+ html = (
97
+ "<html><body style='text-align:center;padding:60px;font-family:sans-serif'>"
98
+ f"<h2>{t['success_title']}</h2>"
99
+ f"<p>{t['success_body']}</p>"
100
+ "</body></html>"
101
+ )
102
+ self.wfile.write(html.encode("utf-8"))
103
+
104
+ def log_message(self, format, *args):
105
+ pass
106
+
107
+ return CallbackHandler
108
+
109
+
110
+ def login(
111
+ key: str = typer.Option("", "--key", "-k", help="MCP Key"),
112
+ browser: bool = typer.Option(False, "--browser", "-b", help="Browser login"),
113
+ server: str = typer.Option("", "--server", "-s", help="Hub Server URL"),
114
+ ):
115
+ """Login to Mobox Hub Server"""
116
+ if browser:
117
+ _browser_login(server)
118
+ return
119
+ _manual_login(key, server)
120
+
121
+
122
+ def _manual_login(key: str, server: str):
123
+ if not key:
124
+ key = Prompt.ask("[bold]MCP Key[/bold]")
125
+ if not key:
126
+ utils.error("MCP Key is required")
127
+ raise typer.Exit(1)
128
+
129
+ if not server:
130
+ server = Prompt.ask(
131
+ "[bold]Hub Server URL[/bold]",
132
+ default=DEFAULT_SERVER,
133
+ )
134
+
135
+ utils.waiting("Verifying key...")
136
+
137
+ result = asyncio.run(_verify_key(key, server))
138
+
139
+ if not result["success"]:
140
+ utils.error(f"Verification failed: {result.get('error', 'unknown')}")
141
+ raise typer.Exit(1)
142
+
143
+ namespace = result.get("namespace", "unknown")
144
+ save_credentials(key, namespace, server)
145
+ utils.success(f"Login successful! namespace={namespace}")
146
+ utils.info(f"Server: {server}")
147
+
148
+
149
+ async def _verify_key(key: str, server_url: str) -> dict:
150
+ """Verify MCP key by calling query list on Hub Server"""
151
+ try:
152
+ from mcp import ClientSession
153
+ from mcp.client.streamable_http import streamablehttp_client
154
+
155
+ async with streamablehttp_client(
156
+ server_url,
157
+ headers={"X-MCP-Key": key},
158
+ ) as (read_stream, write_stream, _):
159
+ async with ClientSession(read_stream, write_stream) as session:
160
+ await session.initialize()
161
+ result = await session.call_tool("query", {
162
+ "description": "verify",
163
+ "mode": "list",
164
+ "options": {"type": "apps"},
165
+ })
166
+ for item in result.content:
167
+ if hasattr(item, "text"):
168
+ try:
169
+ data = json.loads(item.text)
170
+ ns = data.get("namespace", "")
171
+ if ns:
172
+ return {"success": True, "namespace": ns}
173
+ except json.JSONDecodeError:
174
+ pass
175
+ return {"success": True, "namespace": "user"}
176
+
177
+ except Exception as e:
178
+ error_str = str(e)
179
+ # Check HTTP status code patterns more precisely
180
+ for code in ("401", "403"):
181
+ if f"HTTP {code}" in error_str or f"status {code}" in error_str:
182
+ return {"success": False, "error": "Invalid MCP Key"}
183
+ return {"success": False, "error": error_str}
184
+
185
+
186
+ def _browser_login(server: str):
187
+ """Browser-based OAuth-style login"""
188
+ state = secrets.token_hex(32)
189
+ port = random.randint(18900, 18999)
190
+ callback_url = f"http://127.0.0.1:{port}/callback"
191
+
192
+ auth_url = (
193
+ f"https://modelgate.net/mobox/cli-auth"
194
+ f"?callback={quote(callback_url, safe='')}&state={state}"
195
+ )
196
+
197
+ login_result = {"received": False, "error": None}
198
+
199
+ handler_cls = _make_callback_handler(state, login_result, server)
200
+ httpd = HTTPServer(("127.0.0.1", port), handler_cls)
201
+
202
+ utils.waiting(f"Starting local server on 127.0.0.1:{port}...")
203
+ utils.info(f"Opening browser: {auth_url}")
204
+
205
+ webbrowser.open(auth_url)
206
+ utils.waiting("Waiting for login callback (timeout: 5 min)...")
207
+
208
+ # Loop until valid callback or timeout (handles favicon/preflight requests)
209
+ deadline = time.time() + 300
210
+ while not login_result["received"] and not login_result["error"]:
211
+ remaining = deadline - time.time()
212
+ if remaining <= 0:
213
+ break
214
+ httpd.timeout = remaining
215
+ httpd.handle_request()
216
+
217
+ httpd.server_close()
218
+
219
+ if not login_result["received"]:
220
+ if login_result["error"]:
221
+ utils.error(login_result["error"])
222
+ else:
223
+ utils.error("Login timed out. Try again or use --key.")
224
+ raise typer.Exit(1)
225
+
226
+ save_credentials(
227
+ login_result["mcp_key"],
228
+ login_result["namespace"],
229
+ login_result["server_url"],
230
+ )
231
+ utils.success(f"Login successful! namespace={login_result['namespace']}")
232
+ utils.info(f"Server: {login_result['server_url']}")
233
+
234
+
235
+ def logout():
236
+ """Clear local credentials"""
237
+ clear_credentials()
238
+ utils.success("Logged out. Credentials removed.")
239
+
240
+
241
+ def whoami():
242
+ """Show current auth status"""
243
+ creds = load_credentials()
244
+ if not creds or not creds.get("mcp_key"):
245
+ utils.error("Not logged in. Run [bold]mobox login[/bold] first.")
246
+ raise typer.Exit(1)
247
+
248
+ key = creds["mcp_key"]
249
+ masked = key[:4] + "..." + key[-4:] if len(key) > 20 else "***"
250
+
251
+ utils.console.print(f" Namespace: [bold]{creds.get('namespace', 'N/A')}[/bold]")
252
+ utils.console.print(f" Server: {creds.get('server_url', 'N/A')}")
253
+ utils.console.print(f" Key: {masked}")
254
+ utils.console.print(f" Since: {creds.get('created_at', 'N/A')}")
mobox/client.py ADDED
@@ -0,0 +1,98 @@
1
+ """MoboxClient - MCP protocol communication with Hub Server"""
2
+
3
+ import json
4
+ from typing import Any, Optional
5
+
6
+ from mcp import ClientSession
7
+ from mcp.client.streamable_http import streamablehttp_client
8
+
9
+
10
+ class MoboxClient:
11
+ """Wrapper around MCP SDK StreamableHTTP client"""
12
+
13
+ def __init__(self, server_url: str, mcp_key: str):
14
+ self.server_url = server_url
15
+ self.mcp_key = mcp_key
16
+
17
+ async def call_tool(self, tool_name: str, arguments: dict) -> dict:
18
+ """Call a remote MCP tool and return parsed result"""
19
+ try:
20
+ async with streamablehttp_client(
21
+ self.server_url,
22
+ headers={"X-MCP-Key": self.mcp_key},
23
+ ) as (read_stream, write_stream, _):
24
+ async with ClientSession(read_stream, write_stream) as session:
25
+ await session.initialize()
26
+ result = await session.call_tool(tool_name, arguments)
27
+ return self._parse_result(result)
28
+ except Exception as e:
29
+ return {"success": False, "error": str(e)}
30
+
31
+ def _parse_result(self, result) -> dict:
32
+ """Parse MCP CallToolResult into dict"""
33
+ if not result or not result.content:
34
+ return {"success": False, "error": "Empty response"}
35
+
36
+ for item in result.content:
37
+ if hasattr(item, "text"):
38
+ try:
39
+ return json.loads(item.text)
40
+ except json.JSONDecodeError:
41
+ return {"success": True, "text": item.text}
42
+
43
+ return {"success": True, "raw": str(result.content)}
44
+
45
+ # --- Convenience methods ---
46
+
47
+ async def upload(self, mode: str, options: Optional[dict] = None) -> dict:
48
+ args: dict[str, Any] = {"description": "CLI upload", "mode": mode}
49
+ if options:
50
+ args["options"] = options
51
+ return await self.call_tool("upload", args)
52
+
53
+ async def build(self, mode: str, options: Optional[dict] = None) -> dict:
54
+ args: dict[str, Any] = {"description": "CLI build", "mode": mode}
55
+ if options:
56
+ args["options"] = options
57
+ return await self.call_tool("build", args)
58
+
59
+ async def control(self, mode: str, options: Optional[dict] = None) -> dict:
60
+ args: dict[str, Any] = {"description": "CLI control", "mode": mode}
61
+ if options:
62
+ args["options"] = options
63
+ return await self.call_tool("control", args)
64
+
65
+ async def query(self, mode: str, options: Optional[dict] = None) -> dict:
66
+ args: dict[str, Any] = {"description": "CLI query", "mode": mode}
67
+ if options:
68
+ args["options"] = options
69
+ return await self.call_tool("query", args)
70
+
71
+ async def boxbash(self, mode: str, options: Optional[dict] = None) -> dict:
72
+ args: dict[str, Any] = {
73
+ "mode": mode,
74
+ "description": "CLI boxbash",
75
+ }
76
+ if options:
77
+ # command is a top-level param in boxbash, not inside options
78
+ if "command" in options:
79
+ args["command"] = options.pop("command")
80
+ args["options"] = options
81
+ return await self.call_tool("boxbash", args)
82
+
83
+ async def boxfile(self, mode: str, options: Optional[dict] = None) -> dict:
84
+ args: dict[str, Any] = {
85
+ "mode": mode,
86
+ "description": "CLI boxfile",
87
+ }
88
+ if options:
89
+ args["options"] = options
90
+ return await self.call_tool("boxfile", args)
91
+
92
+
93
+ def get_client() -> MoboxClient:
94
+ """Create MoboxClient from saved credentials, exit if not logged in"""
95
+ from mobox.utils import require_auth
96
+
97
+ creds = require_auth()
98
+ return MoboxClient(creds["server_url"], creds["mcp_key"])
File without changes
@@ -0,0 +1,149 @@
1
+ """Deploy commands: upload, deploy"""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import typer
9
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
10
+
11
+ from mobox import utils
12
+ from mobox.client import get_client
13
+
14
+
15
+ def upload(
16
+ file: str = typer.Argument(..., help="Path to tar.gz file"),
17
+ ):
18
+ """Upload package to Hub Server"""
19
+ file_path = Path(file).resolve()
20
+ if not file_path.exists():
21
+ utils.error(f"File not found: {file_path}")
22
+ raise typer.Exit(1)
23
+
24
+ client = get_client()
25
+ upload_name = file_path.name
26
+
27
+ # Step 1: Get upload config
28
+ utils.waiting("Getting upload config...")
29
+ config_result = asyncio.run(
30
+ client.upload("get", {"upload_name": upload_name})
31
+ )
32
+
33
+ if not config_result.get("success"):
34
+ utils.error(config_result.get("error", "Failed to get upload config"))
35
+ raise typer.Exit(1)
36
+
37
+ config = config_result.get("config", {})
38
+ upload_url = config.get("upload_url")
39
+ upload_token = config.get("temp_api_key")
40
+ upload_id = config_result.get("upload_id", "")
41
+
42
+ if not upload_url:
43
+ utils.error("Server did not return upload URL")
44
+ raise typer.Exit(1)
45
+
46
+ # Step 2: Upload file via HTTP
47
+ md5 = _calculate_md5(file_path)
48
+ size = file_path.stat().st_size
49
+
50
+ asyncio.run(_upload_file(upload_url, upload_token, file_path, md5, size))
51
+
52
+ # Step 3: Check upload status
53
+ utils.waiting("Verifying upload...")
54
+ check_result = asyncio.run(
55
+ client.upload("check", {"upload_id": upload_id})
56
+ )
57
+
58
+ if check_result.get("success"):
59
+ uid = check_result.get("upload_id", upload_id)
60
+ utils.success(f"Upload complete, upload_id: {uid}")
61
+ else:
62
+ utils.warn("Upload sent but verification unclear. Check with `mobox apps`.")
63
+
64
+
65
+ async def _upload_file(
66
+ url: str, token: str, path: Path, md5: str, size: int
67
+ ):
68
+ """Upload file via HTTP POST multipart form-data"""
69
+ headers = {"X-Temp-API-Key": token}
70
+
71
+ with Progress(
72
+ SpinnerColumn(),
73
+ TextColumn("[progress.description]{task.description}"),
74
+ BarColumn(),
75
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
76
+ ) as progress:
77
+ task = progress.add_task("Uploading...", total=size)
78
+
79
+ async with httpx.AsyncClient(
80
+ timeout=httpx.Timeout(300.0),
81
+ ) as http:
82
+ with open(path, "rb") as f:
83
+ resp = await http.post(
84
+ url,
85
+ files={"file": (path.name, f, "application/gzip")},
86
+ data={"fileMD5": md5, "fileSize": str(size)},
87
+ headers=headers,
88
+ )
89
+ progress.update(task, completed=size)
90
+
91
+ if resp.status_code not in (200, 201):
92
+ utils.error(f"Upload failed: HTTP {resp.status_code}")
93
+ raise typer.Exit(1)
94
+
95
+
96
+ def deploy(
97
+ app_name: str = typer.Argument(..., help="Application name"),
98
+ upload_id: str = typer.Option(
99
+ "", "--upload-id", "-u", help="Upload ID"
100
+ ),
101
+ action: str = typer.Option(
102
+ "auto", "--action", "-a", help="deploy/update/redeploy/auto"
103
+ ),
104
+ framework: str = typer.Option(
105
+ "", "--framework", "-f", help="Framework (e.g. nextjs/express/vite-react)"
106
+ ),
107
+ ):
108
+ """Deploy or update an application"""
109
+ if action not in ("deploy", "update", "redeploy", "auto"):
110
+ utils.error(f"Invalid action: {action}. Use deploy/update/redeploy/auto")
111
+ raise typer.Exit(1)
112
+
113
+ if not framework:
114
+ config_path = Path.cwd() / "app-deploy.json"
115
+ if config_path.exists():
116
+ try:
117
+ import json
118
+ fw = json.loads(config_path.read_text()).get("framework", "")
119
+ if fw:
120
+ framework = fw
121
+ except Exception:
122
+ pass
123
+
124
+ client = get_client()
125
+ options = {"app_name": app_name, "action": action}
126
+ if upload_id:
127
+ options["upload_id"] = upload_id
128
+ if framework:
129
+ options["framework"] = framework
130
+
131
+ utils.waiting(f"Deploying {app_name} ({action})...")
132
+ result = asyncio.run(client.build("deploy", options))
133
+
134
+ if not result.get("success"):
135
+ utils.error(result.get("error", f"Failed to deploy {app_name}"))
136
+ raise typer.Exit(1)
137
+
138
+ utils.success("Deploy task submitted")
139
+ url = result.get("url", "")
140
+ if url:
141
+ utils.info(f"URL: {url}")
142
+
143
+
144
+ def _calculate_md5(path: Path) -> str:
145
+ md5 = hashlib.md5()
146
+ with open(path, "rb") as f:
147
+ for chunk in iter(lambda: f.read(8192), b""):
148
+ md5.update(chunk)
149
+ return md5.hexdigest()
@@ -0,0 +1,82 @@
1
+ """Local commands: detect, pack"""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from mobox import utils
9
+
10
+
11
+ def detect(
12
+ path: str = typer.Argument(".", help="Project path to detect"),
13
+ ):
14
+ """Detect project framework and generate app-deploy.json"""
15
+ project_path = Path(path).resolve()
16
+ if not project_path.exists():
17
+ utils.error(f"Path not found: {project_path}")
18
+ raise typer.Exit(1)
19
+
20
+ from mobox.local.detect_project import ProjectDetector
21
+
22
+ detector = ProjectDetector(str(project_path))
23
+ result = detector.detect()
24
+
25
+ if not result.get("success"):
26
+ utils.error(result.get("error", "Detection failed"))
27
+ raise typer.Exit(1)
28
+
29
+ config = result.get("config", {})
30
+ utils.success(f"Framework: {config.get('framework', 'unknown')}")
31
+
32
+ build_cmd = config.get("build", {}).get("command", "")
33
+ if build_cmd:
34
+ utils.info(f"Build: {build_cmd}")
35
+
36
+ port = config.get("port")
37
+ if port:
38
+ utils.info(f"Port: {port}")
39
+
40
+ utils.info("app-deploy.json generated")
41
+
42
+
43
+ def pack(
44
+ path: str = typer.Argument(".", help="Project path to pack"),
45
+ name: str = typer.Option("", "--name", "-n", help="Application name"),
46
+ ver: str = typer.Option("", "--version", "-V", help="Version"),
47
+ output: str = typer.Option("", "--output", "-o", help="Output path"),
48
+ ):
49
+ """Pack project into tar.gz for upload"""
50
+ project_path = Path(path).resolve()
51
+ if not project_path.exists():
52
+ utils.error(f"Path not found: {project_path}")
53
+ raise typer.Exit(1)
54
+
55
+ if not output:
56
+ output = str(Path(tempfile.gettempdir()) / "mobox-pack.tar.gz")
57
+
58
+ from mobox.local.pack_project import ProjectPackager
59
+
60
+ packager = ProjectPackager(
61
+ str(project_path),
62
+ output,
63
+ app_name=name or None,
64
+ app_version=ver or None,
65
+ )
66
+ result = packager.package()
67
+
68
+ if not result.get("success"):
69
+ err = result.get("error", "")
70
+ if err == "BUILD_REQUIRED":
71
+ details = result.get("details", {})
72
+ utils.error(
73
+ f"Project needs build first: {details.get('build_command', 'npm run build')}"
74
+ )
75
+ else:
76
+ utils.error(err or "Pack failed")
77
+ raise typer.Exit(1)
78
+
79
+ utils.success(f"Packed: {result['output_path']} ({result['size']})")
80
+ utils.info(
81
+ f"Files: {result['included_files']} included, {result['excluded_files']} excluded"
82
+ )