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
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
|
mobox/commands/deploy.py
ADDED
|
@@ -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()
|
mobox/commands/local.py
ADDED
|
@@ -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
|
+
)
|