hivepods-cli 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/PKG-INFO +13 -8
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/README.md +12 -7
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/__init__.py +1 -1
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/client.py +27 -1
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/deploy.py +146 -5
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/main.py +25 -15
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/mcp_server.py +38 -17
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/PKG-INFO +13 -8
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/pyproject.toml +1 -1
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_client.py +32 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_deploy_cmd.py +64 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_mcp.py +52 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/__main__.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/browser_login.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/config.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/packaging.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/terminal.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/SOURCES.txt +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/dependency_links.txt +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/entry_points.txt +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/requires.txt +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/top_level.txt +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/setup.cfg +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_browser_login.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_config.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_packaging.py +0 -0
- {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_round2.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hivepods-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Command line interface for DockHive / HivePods
|
|
5
5
|
Author: DockHive
|
|
6
6
|
License: MIT
|
|
@@ -86,7 +86,7 @@ hivepod rm api # delete
|
|
|
86
86
|
hivepod list # what you have
|
|
87
87
|
hivepod status api # details + live CPU/RAM
|
|
88
88
|
|
|
89
|
-
hivepod env api
|
|
89
|
+
hivepod env api LOG_LEVEL=info --unset DEBUG # change environment variables
|
|
90
90
|
hivepod apply api # use them (recreates the environment; data/ and projects/ are kept)
|
|
91
91
|
|
|
92
92
|
hivepod backup create api # back up the data/ and projects/ folders
|
|
@@ -106,16 +106,21 @@ Windows 10+ console).
|
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
108
|
cd my-app
|
|
109
|
-
hivepod deploy # upload this folder; Drone builds and
|
|
109
|
+
hivepod deploy # upload this folder; Drone builds and deploys it
|
|
110
110
|
hivepod deploy ./api --env DATABASE_URL=postgres://... # set env vars (repeatable)
|
|
111
111
|
hivepod deploy --app my-api # redeploy a specific app
|
|
112
112
|
hivepod deploy --no-wait # return once the upload is accepted
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
Zero config: **Drone**, DockHive's AI deploy agent, reads your code, builds your app and
|
|
116
|
+
deploys it, and fixes problems along the way so it goes live. You see Drone's progress as
|
|
117
|
+
it works ("Reading your code…", "Building your app…"), then the live URL. If your app
|
|
118
|
+
needs settings such as `DATABASE_URL`, Drone tells you which ones; pass them with `--env`.
|
|
119
|
+
`--verbose` shows the full build log. Deploying the same folder again updates the
|
|
120
|
+
same app (`--new` makes a separate one). If the folder isn't a web service (a mobile or
|
|
121
|
+
desktop app, a CLI or library, a background worker, notebooks, infrastructure config),
|
|
122
|
+
Drone stops before building, says what it found and what to do instead (deploy the
|
|
123
|
+
backend folder, or use a HivePod), and nothing is billed.
|
|
119
124
|
|
|
120
125
|
What gets uploaded: the folder as a compressed archive (50 MB max) without `.git`,
|
|
121
126
|
`node_modules`, virtualenvs, `__pycache__`, tool caches, large `dist`/`build` folders,
|
|
@@ -166,7 +171,7 @@ fail?"*, *"Add a Postgres database and wire it into the app"*.
|
|
|
166
171
|
|
|
167
172
|
| Tool | What it does |
|
|
168
173
|
|------|--------------|
|
|
169
|
-
| `deploy` | Deploy a folder (`path`) or a GitHub repo (`repo_url`) with Drone; waits and returns the URL, or Drone's summary,
|
|
174
|
+
| `deploy` | Deploy a folder (`path`) or a GitHub repo (`repo_url`) with Drone; waits and returns the URL, or Drone's summary of the app, the env vars it needs and the build log tail on failure. Code that isn't a web service (mobile/desktop app, CLI, library, worker, notebooks, infra config) is an error with `kind`, `reason`, `suggestion` and `retryable: false` |
|
|
170
175
|
| `deployment_status`, `build_logs` | Follow a deployment and read its build log |
|
|
171
176
|
| `list_apps`, `app_logs` | Your apps, and a live app's runtime logs |
|
|
172
177
|
| `set_env` | Set/unset an app's environment variables (values are write-only), optionally redeploy |
|
|
@@ -62,7 +62,7 @@ hivepod rm api # delete
|
|
|
62
62
|
hivepod list # what you have
|
|
63
63
|
hivepod status api # details + live CPU/RAM
|
|
64
64
|
|
|
65
|
-
hivepod env api
|
|
65
|
+
hivepod env api LOG_LEVEL=info --unset DEBUG # change environment variables
|
|
66
66
|
hivepod apply api # use them (recreates the environment; data/ and projects/ are kept)
|
|
67
67
|
|
|
68
68
|
hivepod backup create api # back up the data/ and projects/ folders
|
|
@@ -82,16 +82,21 @@ Windows 10+ console).
|
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
84
|
cd my-app
|
|
85
|
-
hivepod deploy # upload this folder; Drone builds and
|
|
85
|
+
hivepod deploy # upload this folder; Drone builds and deploys it
|
|
86
86
|
hivepod deploy ./api --env DATABASE_URL=postgres://... # set env vars (repeatable)
|
|
87
87
|
hivepod deploy --app my-api # redeploy a specific app
|
|
88
88
|
hivepod deploy --no-wait # return once the upload is accepted
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
Zero config: **Drone**, DockHive's AI deploy agent, reads your code, builds your app and
|
|
92
|
+
deploys it, and fixes problems along the way so it goes live. You see Drone's progress as
|
|
93
|
+
it works ("Reading your code…", "Building your app…"), then the live URL. If your app
|
|
94
|
+
needs settings such as `DATABASE_URL`, Drone tells you which ones; pass them with `--env`.
|
|
95
|
+
`--verbose` shows the full build log. Deploying the same folder again updates the
|
|
96
|
+
same app (`--new` makes a separate one). If the folder isn't a web service (a mobile or
|
|
97
|
+
desktop app, a CLI or library, a background worker, notebooks, infrastructure config),
|
|
98
|
+
Drone stops before building, says what it found and what to do instead (deploy the
|
|
99
|
+
backend folder, or use a HivePod), and nothing is billed.
|
|
95
100
|
|
|
96
101
|
What gets uploaded: the folder as a compressed archive (50 MB max) without `.git`,
|
|
97
102
|
`node_modules`, virtualenvs, `__pycache__`, tool caches, large `dist`/`build` folders,
|
|
@@ -142,7 +147,7 @@ fail?"*, *"Add a Postgres database and wire it into the app"*.
|
|
|
142
147
|
|
|
143
148
|
| Tool | What it does |
|
|
144
149
|
|------|--------------|
|
|
145
|
-
| `deploy` | Deploy a folder (`path`) or a GitHub repo (`repo_url`) with Drone; waits and returns the URL, or Drone's summary,
|
|
150
|
+
| `deploy` | Deploy a folder (`path`) or a GitHub repo (`repo_url`) with Drone; waits and returns the URL, or Drone's summary of the app, the env vars it needs and the build log tail on failure. Code that isn't a web service (mobile/desktop app, CLI, library, worker, notebooks, infra config) is an error with `kind`, `reason`, `suggestion` and `retryable: false` |
|
|
146
151
|
| `deployment_status`, `build_logs` | Follow a deployment and read its build log |
|
|
147
152
|
| `list_apps`, `app_logs` | Your apps, and a live app's runtime logs |
|
|
148
153
|
| `set_env` | Set/unset an app's environment variables (values are write-only), optionally redeploy |
|
|
@@ -207,12 +207,15 @@ class APIClient:
|
|
|
207
207
|
timeout=timeout, **kwargs)
|
|
208
208
|
raise APIError("Please sign in first: run `hivepod login`.", 401)
|
|
209
209
|
if resp.status_code == 403:
|
|
210
|
-
raise APIError(
|
|
210
|
+
raise APIError(_forbidden_message(resp), 403, _json_or_none(resp))
|
|
211
211
|
if resp.status_code == 404:
|
|
212
212
|
raise APIError("Not found.", 404, _json_or_none(resp))
|
|
213
213
|
if resp.status_code >= 500:
|
|
214
214
|
raise APIError(_server_message(resp), resp.status_code, _json_or_none(resp))
|
|
215
215
|
if resp.status_code >= 400:
|
|
216
|
+
data = _json_or_none(resp) or {}
|
|
217
|
+
if data.get("code") in BOT_CHECK_CODES:
|
|
218
|
+
raise APIError(BROWSER_SIGN_IN, resp.status_code, data)
|
|
216
219
|
raise APIError(_format_error(resp), resp.status_code, _json_or_none(resp))
|
|
217
220
|
if resp.status_code == 204 or not resp.content:
|
|
218
221
|
return None
|
|
@@ -628,6 +631,29 @@ def _clean(text: str) -> Optional[str]:
|
|
|
628
631
|
return text[:300]
|
|
629
632
|
|
|
630
633
|
|
|
634
|
+
BROWSER_SIGN_IN = ("Email and password sign-in isn't available from the terminal. "
|
|
635
|
+
"Run `hivepod login` to sign in through your browser.")
|
|
636
|
+
BOT_CHECK_CODES = {"turnstile_failed", "turnstile_unavailable", "form_expired"}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _forbidden_message(resp) -> str:
|
|
640
|
+
"""Explain a 403: unverified email, suspended account, bot check, or plain permission."""
|
|
641
|
+
if (getattr(resp, "headers", None) or {}).get("cf-mitigated"):
|
|
642
|
+
return BROWSER_SIGN_IN if "/auth/" in (getattr(resp, "url", "") or "") else (
|
|
643
|
+
"This request was blocked by DockHive's protection. Try again from your browser.")
|
|
644
|
+
data = _json_or_none(resp) or {}
|
|
645
|
+
code = data.get("code")
|
|
646
|
+
if code == "email_unverified":
|
|
647
|
+
return ("Verify your email to continue. Check your inbox for the code, "
|
|
648
|
+
"or open https://console.dockhive.io to resend it.")
|
|
649
|
+
if code == "account_suspended":
|
|
650
|
+
return _clean(data.get("error") or data.get("message") or "") or (
|
|
651
|
+
"Your account is suspended. Contact DockHive support.")
|
|
652
|
+
if code in BOT_CHECK_CODES:
|
|
653
|
+
return BROWSER_SIGN_IN
|
|
654
|
+
return "Permission denied for this resource."
|
|
655
|
+
|
|
656
|
+
|
|
631
657
|
def _server_message(resp) -> str:
|
|
632
658
|
if resp.status_code in (502, 503, 504):
|
|
633
659
|
return "DockHive is temporarily unavailable. Please try again in a moment."
|
|
@@ -32,6 +32,90 @@ def _noop(*_a, **_k):
|
|
|
32
32
|
return None
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
# Deployment stages in the user's words (the API's status values are an enum).
|
|
36
|
+
STATUS_LABELS = {
|
|
37
|
+
"queued": "Queued", "uploading": "Uploading", "extracting": "Unpacking",
|
|
38
|
+
"cloning": "Fetching code", "building": "Building", "starting": "Starting",
|
|
39
|
+
"health_check": "Starting your app", "switching": "Going live", "deployed": "Live",
|
|
40
|
+
"failed": "Failed",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def status_label(status: Optional[str]) -> str:
|
|
45
|
+
return STATUS_LABELS.get(status or "", status or "Working")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- Drone log
|
|
49
|
+
# Older deployers wrote how Drone works inside into the build log (the build file it
|
|
50
|
+
# wrote, the port it picked, AI usage). Those lines are hidden or reworded; current
|
|
51
|
+
# deployers no longer write them. The raw build output itself is kept for debugging.
|
|
52
|
+
_DRONE_FIX_RE = re.compile(r"^\[drone\] (?:build failed|health check failed|asking again)\b.*?"
|
|
53
|
+
r"\(attempt (\d+)/(\d+)\)", re.I)
|
|
54
|
+
_DRONE_HIDDEN_RE = re.compile(r"^\[drone\] (?:detected[: ]|plan:|ai usage|the ai|your saved build setup "
|
|
55
|
+
r"failed|reusing your saved)", re.I)
|
|
56
|
+
_DRONE_INTERNALS_RE = re.compile(r"dockerfile|health[ -]?check|health path|\bports?\b|base image|"
|
|
57
|
+
r"\bpreset\b|ai provider|\btokens?\b", re.I)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def drone_log_filter() -> Callable[[str], Optional[str]]:
|
|
61
|
+
"""A per-stream filter: the line to show (reworded when an older deployer wrote it),
|
|
62
|
+
or None for a line that describes Drone's internals."""
|
|
63
|
+
state = {"in_build_file": False}
|
|
64
|
+
|
|
65
|
+
def keep(line: str) -> Optional[str]:
|
|
66
|
+
if not isinstance(line, str):
|
|
67
|
+
return None
|
|
68
|
+
if state["in_build_file"]:
|
|
69
|
+
if line.startswith(" | "):
|
|
70
|
+
return None
|
|
71
|
+
state["in_build_file"] = False
|
|
72
|
+
if not line.startswith(DRONE_PREFIX):
|
|
73
|
+
return line
|
|
74
|
+
if re.match(r"^\[drone\] wrote dockerfile", line, re.I):
|
|
75
|
+
state["in_build_file"] = True
|
|
76
|
+
return "[drone] Building your app…"
|
|
77
|
+
m = _DRONE_FIX_RE.match(line)
|
|
78
|
+
if m:
|
|
79
|
+
return f"[drone] Found a problem, fixing it (attempt {m.group(1)}/{m.group(2)})…"
|
|
80
|
+
if re.match(r"^\[drone\] build and health check passed", line, re.I):
|
|
81
|
+
return "[drone] Your app is running, going live…"
|
|
82
|
+
m = re.match(r"^\[drone\] giving up after (\d+)", line, re.I)
|
|
83
|
+
if m:
|
|
84
|
+
return f"[drone] Couldn't get your app live after {m.group(1)} attempts"
|
|
85
|
+
m = re.match(r"^\[drone\] missing environment variables: ([A-Za-z0-9_, ]+)", line, re.I)
|
|
86
|
+
if m:
|
|
87
|
+
return f"[drone] Your app needs these environment variables: {m.group(1).strip()}"
|
|
88
|
+
if _DRONE_HIDDEN_RE.match(line) or _DRONE_INTERNALS_RE.search(line):
|
|
89
|
+
return None
|
|
90
|
+
return line
|
|
91
|
+
|
|
92
|
+
return keep
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def filter_drone_log(lines: List[str]) -> List[str]:
|
|
96
|
+
keep = drone_log_filter()
|
|
97
|
+
return [kept for kept in (keep(line) for line in lines) if kept is not None]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def drone_message(message: Optional[str]) -> str:
|
|
101
|
+
"""A Drone deployment's error in user terms (older deployers put internals in it)."""
|
|
102
|
+
m = (message or "").strip()
|
|
103
|
+
if not m:
|
|
104
|
+
return ""
|
|
105
|
+
found = re.match(r"^missing environment variables: ([A-Za-z0-9_, ]+)", m, re.I)
|
|
106
|
+
if found:
|
|
107
|
+
return (f"Your app needs these environment variables: {found.group(1).strip()}. "
|
|
108
|
+
"Add them and deploy again.")
|
|
109
|
+
found = re.match(r"^Drone could not deploy this app after (\d+)", m, re.I)
|
|
110
|
+
if found:
|
|
111
|
+
return (f"Drone couldn't get this app live after {found.group(1)} attempts. Check the build "
|
|
112
|
+
"log, fix the problem in your code and deploy again.")
|
|
113
|
+
if _DRONE_INTERNALS_RE.search(m):
|
|
114
|
+
return ("Drone couldn't get this app live. Check the build log, fix the problem in your "
|
|
115
|
+
"code and deploy again.")
|
|
116
|
+
return m
|
|
117
|
+
|
|
118
|
+
|
|
35
119
|
# --------------------------------------------------------------------------- inputs
|
|
36
120
|
def parse_env_pairs(items) -> Dict[str, str]:
|
|
37
121
|
"""['A=1', 'B=x=y'] -> {'A': '1', 'B': 'x=y'}; raises ValueError on a bad item."""
|
|
@@ -235,11 +319,12 @@ def wait_for_deployment(client: APIClient, deployment: dict, *, timeout: float =
|
|
|
235
319
|
errors = 0
|
|
236
320
|
tick = 0
|
|
237
321
|
last_status = None
|
|
322
|
+
keep = drone_log_filter()
|
|
238
323
|
|
|
239
324
|
def pull_logs():
|
|
240
325
|
nonlocal after
|
|
241
326
|
chunk = client.deployment_logs(dep_id, after, region)
|
|
242
|
-
new = [str(x) for x in chunk["lines"]]
|
|
327
|
+
new = [kept for kept in (keep(str(x)) for x in chunk["lines"]) if kept is not None]
|
|
243
328
|
after = chunk["next"]
|
|
244
329
|
if new:
|
|
245
330
|
lines.extend(new)
|
|
@@ -290,6 +375,24 @@ def deployment_url(dep: dict) -> Optional[str]:
|
|
|
290
375
|
return None
|
|
291
376
|
|
|
292
377
|
|
|
378
|
+
NOT_DEPLOYABLE = "not_deployable"
|
|
379
|
+
KIND_LABELS = {
|
|
380
|
+
"web_app": "a web app", "api": "an API", "static_site": "a static site",
|
|
381
|
+
"fullstack": "a full-stack app", "worker": "a background worker", "cli": "a command-line tool",
|
|
382
|
+
"library": "a library", "mobile_app": "a mobile app", "desktop_app": "a desktop app",
|
|
383
|
+
"notebook": "a set of notebooks", "infra_config": "an infrastructure/config repository",
|
|
384
|
+
"empty": "an empty repository", "other": "something other than a web service",
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def kind_label(kind: Optional[str]) -> str:
|
|
389
|
+
return KIND_LABELS.get(kind or "", KIND_LABELS["other"])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _text(v: Any) -> str:
|
|
393
|
+
return " ".join(v.split()) if isinstance(v, str) else ""
|
|
394
|
+
|
|
395
|
+
|
|
293
396
|
def drone_info(dep: dict) -> Optional[Dict[str, Any]]:
|
|
294
397
|
d = dep.get("drone")
|
|
295
398
|
if not isinstance(d, dict):
|
|
@@ -303,9 +406,34 @@ def drone_info(dep: dict) -> Optional[Dict[str, Any]]:
|
|
|
303
406
|
}
|
|
304
407
|
if isinstance(d.get("max_attempts"), int):
|
|
305
408
|
out["max_attempts"] = d["max_attempts"]
|
|
409
|
+
if out["status"] == NOT_DEPLOYABLE:
|
|
410
|
+
out["kind"] = d.get("kind") if isinstance(d.get("kind"), str) else "other"
|
|
411
|
+
out["reason"] = _text(d.get("reason"))
|
|
412
|
+
out["suggestion"] = _text(d.get("suggestion"))
|
|
413
|
+
root = d.get("suggested_root_directory")
|
|
414
|
+
out["suggested_root_directory"] = root if isinstance(root, str) and root else None
|
|
306
415
|
return out
|
|
307
416
|
|
|
308
417
|
|
|
418
|
+
def not_deployable(info: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
419
|
+
"""{kind, reason, suggestion, suggested_root_directory} when Drone refused the code
|
|
420
|
+
(it is not a web service), else None."""
|
|
421
|
+
if not info or info.get("status") != NOT_DEPLOYABLE:
|
|
422
|
+
return None
|
|
423
|
+
return {k: info.get(k) for k in ("kind", "reason", "suggestion", "suggested_root_directory")}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def not_deployable_text(nd: Dict[str, Any]) -> str:
|
|
427
|
+
"""One sentence: "Drone can't deploy this: it looks like a mobile app (reason). Suggestion"."""
|
|
428
|
+
text = f"Drone can't deploy this: it looks like {kind_label(nd.get('kind'))}, not a web service"
|
|
429
|
+
if nd.get("reason"):
|
|
430
|
+
text += f" ({nd['reason'].rstrip('.')})"
|
|
431
|
+
text += "."
|
|
432
|
+
if nd.get("suggestion"):
|
|
433
|
+
text += " " + nd["suggestion"]
|
|
434
|
+
return text
|
|
435
|
+
|
|
436
|
+
|
|
309
437
|
def summarize(dep: dict, lines: List[str], outcome: str = "finished",
|
|
310
438
|
source: str = "upload") -> Dict[str, Any]:
|
|
311
439
|
"""The result an agent (or the CLI) needs: status, URL, Drone notes, failure log."""
|
|
@@ -321,9 +449,9 @@ def summarize(dep: dict, lines: List[str], outcome: str = "finished",
|
|
|
321
449
|
out["version"] = dep.get("version")
|
|
322
450
|
if dep.get("region"):
|
|
323
451
|
out["region"] = dep["region"]
|
|
324
|
-
if dep.get("message"):
|
|
325
|
-
out["message"] = dep["message"]
|
|
326
452
|
drone = drone_info(dep)
|
|
453
|
+
if dep.get("message"):
|
|
454
|
+
out["message"] = drone_message(dep["message"]) if drone else dep["message"]
|
|
327
455
|
if drone:
|
|
328
456
|
out["drone"] = drone
|
|
329
457
|
if outcome == "timeout":
|
|
@@ -331,16 +459,29 @@ def summarize(dep: dict, lines: List[str], outcome: str = "finished",
|
|
|
331
459
|
"Check it with deployment_status.")
|
|
332
460
|
elif outcome == "cancelled":
|
|
333
461
|
out["note"] = "Stopped waiting (cancelled); the deployment continues on the server."
|
|
462
|
+
nd = not_deployable(drone)
|
|
463
|
+
if nd:
|
|
464
|
+
# Drone read the code and stopped before building: nothing to fix by retrying
|
|
465
|
+
out["not_deployable"] = nd
|
|
466
|
+
out["retryable"] = False
|
|
467
|
+
hints = [nd["suggestion"]] if nd.get("suggestion") else []
|
|
468
|
+
if nd.get("suggested_root_directory"):
|
|
469
|
+
hints.append(f"Deploy the `{nd['suggested_root_directory']}` folder instead (set the app's "
|
|
470
|
+
f"Root directory to it, or deploy that folder's path).")
|
|
471
|
+
hints.append("Don't retry the same deploy: Drone will refuse it again until the code or the "
|
|
472
|
+
"Root directory changes.")
|
|
473
|
+
out["next_steps"] = hints
|
|
474
|
+
return out
|
|
334
475
|
if status == "failed" or (outcome == "finished" and status not in ("deployed",)):
|
|
335
476
|
out["log_tail"] = lines[-FAIL_LOG_LINES:]
|
|
336
477
|
hints = []
|
|
337
478
|
if drone and drone["missing_env"]:
|
|
338
479
|
names = ", ".join(drone["missing_env"])
|
|
339
480
|
if source == "upload":
|
|
340
|
-
hints.append(f"
|
|
481
|
+
hints.append(f"Your app needs these environment variables: {names}. Deploy again "
|
|
341
482
|
f"with env set (or set_env, then deploy the folder again).")
|
|
342
483
|
else:
|
|
343
|
-
hints.append(f"
|
|
484
|
+
hints.append(f"Your app needs these environment variables: {names}. Call set_env "
|
|
344
485
|
f"with redeploy=true.")
|
|
345
486
|
if hints:
|
|
346
487
|
out["next_steps"] = hints
|
|
@@ -70,7 +70,8 @@ def login(email, password, use_browser, url):
|
|
|
70
70
|
try:
|
|
71
71
|
result = start_login(cfg["web_url"])
|
|
72
72
|
except RuntimeError as exc:
|
|
73
|
-
die(f"{exc}\
|
|
73
|
+
die(f"{exc}\nIf no browser can open here, create an API key in the console "
|
|
74
|
+
"(Settings → API keys) and set HIVEPODS_TOKEN.")
|
|
74
75
|
update_config(token=result.token, refresh_token=result.refresh)
|
|
75
76
|
console.print("[green]✓ Signed in[/green]")
|
|
76
77
|
return
|
|
@@ -794,19 +795,14 @@ def exec_cmd(pod, command):
|
|
|
794
795
|
# --------------------------------------------------------------------------
|
|
795
796
|
# apps: deploy
|
|
796
797
|
# --------------------------------------------------------------------------
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
"cloning": "Fetching code", "building": "Building", "starting": "Starting",
|
|
800
|
-
"health_check": "Checking health", "switching": "Going live",
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
@cli.command(help="Deploy the app in PATH (default: this folder) with Drone, zero config.")
|
|
798
|
+
@cli.command(help="Deploy the app in PATH (default: this folder) with Drone, DockHive's AI deploy "
|
|
799
|
+
"agent: it reads your code, builds your app and deploys it. Zero config.")
|
|
805
800
|
@click.argument("path", required=False, default=".", type=click.Path(file_okay=False))
|
|
806
801
|
@click.option("--name", default=None, help="Name for a new app (default: the folder name).")
|
|
807
802
|
@click.option("--app", "project_id", default=None, help="Id of an existing app to redeploy.")
|
|
808
803
|
@click.option("--framework", default=None,
|
|
809
|
-
help="Build mode: drone (default for new apps), a framework id, or dockerfile
|
|
804
|
+
help="Build mode: drone (default for new apps), a framework id, or dockerfile "
|
|
805
|
+
"(use your own Dockerfile).")
|
|
810
806
|
@click.option("--env", "env", multiple=True, help="Environment variable KEY=VALUE (repeatable).")
|
|
811
807
|
@click.option("--root", "root_directory", default=None, help="Sub-folder holding the app (monorepos).")
|
|
812
808
|
@click.option("--new", "new_app", is_flag=True, help="Create a new app even if this folder was deployed before.")
|
|
@@ -866,10 +862,12 @@ def deploy(path, name, project_id, framework, env, root_directory, new_app, no_w
|
|
|
866
862
|
console.out(line, style="dim", highlight=False)
|
|
867
863
|
|
|
868
864
|
def on_status(d):
|
|
869
|
-
label =
|
|
865
|
+
label = dp.status_label(d.get("status"))
|
|
870
866
|
drone = d.get("drone") if isinstance(d.get("drone"), dict) else None
|
|
871
|
-
if drone and drone.get("
|
|
872
|
-
label
|
|
867
|
+
if drone and drone.get("status") == "fixing":
|
|
868
|
+
label = f"Fixing a problem (attempt {drone.get('attempts', 0) + 1})"
|
|
869
|
+
elif drone and drone.get("status") == "analyzing":
|
|
870
|
+
label = "Reading your code"
|
|
873
871
|
spinner.update(f"{label} {pid}…")
|
|
874
872
|
|
|
875
873
|
try:
|
|
@@ -886,13 +884,25 @@ def deploy(path, name, project_id, framework, env, root_directory, new_app, no_w
|
|
|
886
884
|
if result["live"]:
|
|
887
885
|
console.print(f"\n[bold green]✓ Live[/bold green] {result.get('url') or ''}")
|
|
888
886
|
return
|
|
887
|
+
nd = result.get("not_deployable")
|
|
888
|
+
if nd:
|
|
889
|
+
console.print(f"\n[bold yellow]✗ Not deployable as an app[/bold yellow] "
|
|
890
|
+
f"(this looks like {dp.kind_label(nd.get('kind'))})", highlight=False)
|
|
891
|
+
if nd.get("reason"):
|
|
892
|
+
console.print(f"[magenta]drone[/magenta] {nd['reason']}", highlight=False)
|
|
893
|
+
for step in result.get("next_steps") or []:
|
|
894
|
+
console.print(f" → {step}", highlight=False)
|
|
895
|
+
console.print("[dim]Nothing was built, so no build minutes were used.[/dim]")
|
|
896
|
+
sys.exit(1)
|
|
889
897
|
console.print(f"\n[bold red]✗ Deployment {result['status']}[/bold red]")
|
|
890
898
|
drone = result.get("drone") or {}
|
|
891
899
|
if drone.get("summary"):
|
|
892
900
|
console.print(f"[magenta]drone[/magenta] {drone['summary']}", highlight=False)
|
|
893
901
|
if drone.get("missing_env"):
|
|
894
|
-
console.print("[yellow]
|
|
895
|
-
+ "\nDeploy again with --env NAME=value for each.")
|
|
902
|
+
console.print("[yellow]Your app needs these environment variables:[/yellow] "
|
|
903
|
+
+ ", ".join(drone["missing_env"]) + "\nDeploy again with --env NAME=value for each.")
|
|
904
|
+
elif drone and result.get("message"):
|
|
905
|
+
console.print(result["message"], highlight=False)
|
|
896
906
|
if result.get("log_tail") and not verbose:
|
|
897
907
|
console.print("[dim]Last build log lines:[/dim]")
|
|
898
908
|
for line in result["log_tail"]:
|
|
@@ -46,11 +46,15 @@ INSTRUCTIONS = """\
|
|
|
46
46
|
DockHive hosts apps, databases and cloud dev environments (HivePods).
|
|
47
47
|
|
|
48
48
|
Deploying: call `deploy` with `path` set to the project folder (absolute path). Drone, \
|
|
49
|
-
DockHive's AI deploy agent,
|
|
50
|
-
|
|
51
|
-
it returns Drone's summary
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
DockHive's AI deploy agent, reads the code, builds the app and deploys it, fixing \
|
|
50
|
+
problems along the way; no configuration is needed. The tool waits until the app is live \
|
|
51
|
+
and returns its URL; on failure it returns Drone's summary of the app, any environment \
|
|
52
|
+
variables the app needs and the end of the build log. If Drone says the code is not deployable as an app (a mobile or desktop app, CLI, \
|
|
53
|
+
library, background worker, notebooks, infrastructure config or an empty repo), the error \
|
|
54
|
+
has `kind`, `reason`, `suggestion` and `retryable: false`: don't retry the same deploy, \
|
|
55
|
+
tell the user and follow the suggestion (e.g. deploy the backend folder, or use a \
|
|
56
|
+
HivePod). To update an app, deploy the same folder again (it is linked to the app it \
|
|
57
|
+
created) or pass `project_id`. Put configuration in `env`; local .env files and keys are never \
|
|
54
58
|
uploaded.
|
|
55
59
|
|
|
56
60
|
Need a database? `create_database`, then put the returned connection string in the \
|
|
@@ -501,7 +505,7 @@ def _follow(ctx: Context, dep: dict, wait: bool, timeout_seconds: int, source: s
|
|
|
501
505
|
ctx.progress(line[len(deploy_mod.DRONE_PREFIX):])
|
|
502
506
|
|
|
503
507
|
def on_status(d):
|
|
504
|
-
ctx.progress(
|
|
508
|
+
ctx.progress(deploy_mod.status_label(d.get("status")))
|
|
505
509
|
|
|
506
510
|
final, lines, outcome = deploy_mod.wait_for_deployment(
|
|
507
511
|
ctx.client, dep, timeout=timeout_seconds, region=dep.get("region"),
|
|
@@ -516,31 +520,45 @@ def _follow(ctx: Context, dep: dict, wait: bool, timeout_seconds: int, source: s
|
|
|
516
520
|
out["summary"] = f"Still {out['status']} after {timeout_seconds}s; it keeps going on the server."
|
|
517
521
|
elif outcome == "cancelled":
|
|
518
522
|
out["summary"] = "Stopped waiting; the deployment continues on the server."
|
|
523
|
+
elif out.get("not_deployable"):
|
|
524
|
+
nd = out["not_deployable"]
|
|
525
|
+
out["summary"] = deploy_mod.not_deployable_text(nd)
|
|
526
|
+
# structured, so an agent can tell this apart from a build failure and not retry
|
|
527
|
+
raise ToolError(out["summary"] + " Not retryable: change the code or the Root directory first.",
|
|
528
|
+
{**out, "kind": nd.get("kind"), "reason": nd.get("reason"),
|
|
529
|
+
"suggestion": nd.get("suggestion")})
|
|
519
530
|
else:
|
|
520
531
|
drone = out.get("drone") or {}
|
|
521
|
-
|
|
532
|
+
parts = [f"Deployment {out['status']}."]
|
|
533
|
+
if drone and out.get("message"):
|
|
534
|
+
parts.append(out["message"].rstrip(".") + ".")
|
|
535
|
+
if drone.get("summary"):
|
|
536
|
+
parts.append(f"Drone: {drone['summary']}")
|
|
537
|
+
out["summary"] = " ".join(parts)
|
|
522
538
|
if out["status"] == "failed":
|
|
523
539
|
raise ToolError(out["summary"], out)
|
|
524
540
|
return out
|
|
525
541
|
|
|
526
542
|
|
|
527
543
|
@tool("deploy", "Deploy an app",
|
|
528
|
-
"Deploy an app to DockHive with Drone, the AI deploy agent:
|
|
529
|
-
"
|
|
544
|
+
"Deploy an app to DockHive with Drone, the AI deploy agent: it reads the code, builds "
|
|
545
|
+
"the app and deploys it, zero config. Give `path` (the project folder, absolute path; defaults to the server's "
|
|
530
546
|
"working directory) to upload local code, or `repo_url` (+ optional `branch`) to deploy "
|
|
531
547
|
"from GitHub. The folder is packaged as tar.gz (max 50 MB compressed) without .git, "
|
|
532
548
|
"node_modules, virtualenvs, caches, .env files, keys, or anything in .gitignore / "
|
|
533
549
|
".dockhiveignore. Redeploying the same folder updates the app it created; pass "
|
|
534
550
|
"`project_id` to target a specific app, or `new_app: true` to force a new one. With "
|
|
535
|
-
"wait=true (default) it returns when the app is live (URL) or failed (Drone summary
|
|
536
|
-
"
|
|
551
|
+
"wait=true (default) it returns when the app is live (URL) or failed (Drone's summary "
|
|
552
|
+
"of the app, env vars it needs, last 60 build-log lines). Code that is not a web service (mobile/"
|
|
553
|
+
"desktop app, CLI, library, worker, notebooks, infra config) fails fast with "
|
|
554
|
+
"kind/reason/suggestion and retryable=false: do not retry it unchanged.",
|
|
537
555
|
{
|
|
538
556
|
"path": {"type": "string", "description": "Absolute path of the project folder to upload. Defaults to the MCP server's working directory. Not with repo_url."},
|
|
539
557
|
"repo_url": {"type": "string", "description": "https:// Git repository URL to deploy instead of a local folder (uses your connected GitHub account for private repos)."},
|
|
540
558
|
"branch": {"type": "string", "description": "Branch to deploy (repo_url only; default: the repo's main branch)."},
|
|
541
559
|
"name": {"type": "string", "maxLength": 100, "description": "Display name for a new app (default: the folder or repo name)."},
|
|
542
560
|
"project_id": PROJECT_ID,
|
|
543
|
-
"framework": {"type": "string", "description": "Build mode. \"drone\" (default for new apps) lets the AI agent build it; a framework id (e.g. \"nextjs\", \"django\")
|
|
561
|
+
"framework": {"type": "string", "description": "Build mode. \"drone\" (default for new apps) lets Drone, the AI deploy agent, build and deploy it; a framework id (e.g. \"nextjs\", \"django\") uses that framework's setup, and \"dockerfile\" uses the project's own Dockerfile. Existing apps keep their setting unless given."},
|
|
544
562
|
"env": ENV_OBJ,
|
|
545
563
|
"root_directory": {"type": "string", "description": "Sub-folder that holds the app, for monorepos (e.g. \"apps/web\")."},
|
|
546
564
|
"new_app": {"type": "boolean", "default": False, "description": "Create a new app even if this folder was deployed before."},
|
|
@@ -583,7 +601,7 @@ def t_deploy(ctx: Context, path: Optional[str] = None, repo_url: Optional[str] =
|
|
|
583
601
|
|
|
584
602
|
@tool("deployment_status", "Deployment status",
|
|
585
603
|
"Current status of one deployment: queued/building/.../deployed/failed, the URL, "
|
|
586
|
-
"Drone's progress (status, attempts, summary,
|
|
604
|
+
"Drone's progress (status, attempts, summary of the app, env vars it needs) and, if it failed, "
|
|
587
605
|
"the last 60 build-log lines.",
|
|
588
606
|
{"deployment_id": DEPLOYMENT_ID}, required=("deployment_id",), read_only=True)
|
|
589
607
|
def t_deployment_status(ctx: Context, deployment_id: str):
|
|
@@ -599,13 +617,16 @@ def t_deployment_status(ctx: Context, deployment_id: str):
|
|
|
599
617
|
out = deploy_mod.summarize(dep, lines, outcome)
|
|
600
618
|
if outcome == "running":
|
|
601
619
|
out.pop("log_tail", None)
|
|
602
|
-
out["summary"] = f"In progress: {status}."
|
|
620
|
+
out["summary"] = f"In progress: {deploy_mod.status_label(status)}."
|
|
621
|
+
elif out.get("not_deployable"):
|
|
622
|
+
out["summary"] = deploy_mod.not_deployable_text(out["not_deployable"])
|
|
603
623
|
else:
|
|
604
624
|
out["summary"] = f"Live at {out['url']}" if out["live"] else f"Deployment {status}."
|
|
605
625
|
return out
|
|
606
626
|
|
|
607
627
|
|
|
608
628
|
def _all_logs(client: APIClient, deployment_id: str, region: Optional[str]) -> List[str]:
|
|
629
|
+
"""The whole build log, without lines that describe Drone's internals."""
|
|
609
630
|
lines: List[str] = []
|
|
610
631
|
after = 0
|
|
611
632
|
for _ in range(20):
|
|
@@ -616,12 +637,12 @@ def _all_logs(client: APIClient, deployment_id: str, region: Optional[str]) -> L
|
|
|
616
637
|
if chunk["next"] <= after:
|
|
617
638
|
break
|
|
618
639
|
after = chunk["next"]
|
|
619
|
-
return lines
|
|
640
|
+
return deploy_mod.filter_drone_log(lines)
|
|
620
641
|
|
|
621
642
|
|
|
622
643
|
@tool("build_logs", "Build logs",
|
|
623
|
-
"Build/deploy log of a deployment (
|
|
624
|
-
"
|
|
644
|
+
"Build/deploy log of a deployment (fetching code, building, Drone's `[drone]` notes, "
|
|
645
|
+
"going live). Returns the last `tail` lines.",
|
|
625
646
|
{"deployment_id": DEPLOYMENT_ID,
|
|
626
647
|
"tail": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200,
|
|
627
648
|
"description": "Number of lines from the end (default 200)."}},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hivepods-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Command line interface for DockHive / HivePods
|
|
5
5
|
Author: DockHive
|
|
6
6
|
License: MIT
|
|
@@ -86,7 +86,7 @@ hivepod rm api # delete
|
|
|
86
86
|
hivepod list # what you have
|
|
87
87
|
hivepod status api # details + live CPU/RAM
|
|
88
88
|
|
|
89
|
-
hivepod env api
|
|
89
|
+
hivepod env api LOG_LEVEL=info --unset DEBUG # change environment variables
|
|
90
90
|
hivepod apply api # use them (recreates the environment; data/ and projects/ are kept)
|
|
91
91
|
|
|
92
92
|
hivepod backup create api # back up the data/ and projects/ folders
|
|
@@ -106,16 +106,21 @@ Windows 10+ console).
|
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
108
|
cd my-app
|
|
109
|
-
hivepod deploy # upload this folder; Drone builds and
|
|
109
|
+
hivepod deploy # upload this folder; Drone builds and deploys it
|
|
110
110
|
hivepod deploy ./api --env DATABASE_URL=postgres://... # set env vars (repeatable)
|
|
111
111
|
hivepod deploy --app my-api # redeploy a specific app
|
|
112
112
|
hivepod deploy --no-wait # return once the upload is accepted
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
Zero config: **Drone**, DockHive's AI deploy agent, reads your code, builds your app and
|
|
116
|
+
deploys it, and fixes problems along the way so it goes live. You see Drone's progress as
|
|
117
|
+
it works ("Reading your code…", "Building your app…"), then the live URL. If your app
|
|
118
|
+
needs settings such as `DATABASE_URL`, Drone tells you which ones; pass them with `--env`.
|
|
119
|
+
`--verbose` shows the full build log. Deploying the same folder again updates the
|
|
120
|
+
same app (`--new` makes a separate one). If the folder isn't a web service (a mobile or
|
|
121
|
+
desktop app, a CLI or library, a background worker, notebooks, infrastructure config),
|
|
122
|
+
Drone stops before building, says what it found and what to do instead (deploy the
|
|
123
|
+
backend folder, or use a HivePod), and nothing is billed.
|
|
119
124
|
|
|
120
125
|
What gets uploaded: the folder as a compressed archive (50 MB max) without `.git`,
|
|
121
126
|
`node_modules`, virtualenvs, `__pycache__`, tool caches, large `dist`/`build` folders,
|
|
@@ -166,7 +171,7 @@ fail?"*, *"Add a Postgres database and wire it into the app"*.
|
|
|
166
171
|
|
|
167
172
|
| Tool | What it does |
|
|
168
173
|
|------|--------------|
|
|
169
|
-
| `deploy` | Deploy a folder (`path`) or a GitHub repo (`repo_url`) with Drone; waits and returns the URL, or Drone's summary,
|
|
174
|
+
| `deploy` | Deploy a folder (`path`) or a GitHub repo (`repo_url`) with Drone; waits and returns the URL, or Drone's summary of the app, the env vars it needs and the build log tail on failure. Code that isn't a web service (mobile/desktop app, CLI, library, worker, notebooks, infra config) is an error with `kind`, `reason`, `suggestion` and `retryable: false` |
|
|
170
175
|
| `deployment_status`, `build_logs` | Follow a deployment and read its build log |
|
|
171
176
|
| `list_apps`, `app_logs` | Your apps, and a live app's runtime logs |
|
|
172
177
|
| `set_env` | Set/unset an app's environment variables (values are write-only), optionally redeploy |
|
|
@@ -226,3 +226,35 @@ def test_os_listing_has_no_internal_column(monkeypatch, tmp_path):
|
|
|
226
226
|
for word in ("backend", "container", " vm "):
|
|
227
227
|
assert word not in out
|
|
228
228
|
assert "full machine" in out and "lightweight" in out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.parametrize("body, expected", [
|
|
232
|
+
({"error": "x", "code": "email_unverified"}, "Verify your email to continue"),
|
|
233
|
+
({"error": "Your account is suspended.", "code": "account_suspended"}, "Your account is suspended."),
|
|
234
|
+
({"detail": "nope"}, "Permission denied for this resource."),
|
|
235
|
+
])
|
|
236
|
+
def test_forbidden_explains_why(calls, body, expected):
|
|
237
|
+
log, routes = calls
|
|
238
|
+
base = "https://gw.dockhive.sh"
|
|
239
|
+
routes[("GET", f"{base}/api/v1/hivepods/")] = lambda h, kw: FakeResp(403, body)
|
|
240
|
+
with pytest.raises(APIError) as exc:
|
|
241
|
+
APIClient(base, "tok").list_pods()
|
|
242
|
+
assert expected in str(exc.value)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_password_login_blocked_by_bot_check_points_to_browser(calls):
|
|
246
|
+
log, routes = calls
|
|
247
|
+
base = "https://gw.dockhive.sh"
|
|
248
|
+
challenged = FakeResp(403, text="<html>challenge</html>")
|
|
249
|
+
challenged.headers = {"cf-mitigated": "challenge"}
|
|
250
|
+
challenged.url = f"{base}/api/v1/auth/login"
|
|
251
|
+
routes[("POST", f"{base}/api/v1/auth/login")] = lambda h, kw: challenged
|
|
252
|
+
with pytest.raises(APIError) as exc:
|
|
253
|
+
APIClient(base).login("a@b.co", "pw")
|
|
254
|
+
assert "hivepod login" in str(exc.value)
|
|
255
|
+
|
|
256
|
+
routes[("POST", f"{base}/api/v1/auth/login")] = \
|
|
257
|
+
lambda h, kw: FakeResp(400, {"error": "Complete the check.", "code": "turnstile_failed"})
|
|
258
|
+
with pytest.raises(APIError) as exc:
|
|
259
|
+
APIClient(base).login("a@b.co", "pw")
|
|
260
|
+
assert "browser" in str(exc.value)
|
|
@@ -59,6 +59,27 @@ def test_deploy_failure_exit_code(http, tmp_path):
|
|
|
59
59
|
assert "DATABASE_URL" in res.output and "--env" in res.output
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def test_deploy_not_deployable_prints_the_suggestion(http, tmp_path):
|
|
63
|
+
from .test_mcp import NOT_DEPLOYABLE
|
|
64
|
+
root = make_project(tmp_path / "shop")
|
|
65
|
+
deploy_routes(http, final="failed", drone={**NOT_DEPLOYABLE, "kind": "cli",
|
|
66
|
+
"reason": "It is a click command-line tool.",
|
|
67
|
+
"suggestion": "Run it in a HivePod, or add a web interface "
|
|
68
|
+
"or API to deploy it.",
|
|
69
|
+
"suggested_root_directory": None},
|
|
70
|
+
logs=["[drone] Reading your code…",
|
|
71
|
+
"[drone] This looks like a command-line tool: It is a click command-line tool.",
|
|
72
|
+
"[drone] Not deployable as an app: Run it in a HivePod, or add a web interface "
|
|
73
|
+
"or API to deploy it."])
|
|
74
|
+
res = run("deploy", str(root))
|
|
75
|
+
assert res.exit_code == 1
|
|
76
|
+
assert "Not deployable as an app" in res.output and "this looks like a command-line tool" in res.output
|
|
77
|
+
assert "It is a click command-line tool." in res.output
|
|
78
|
+
assert "Run it in a HivePod, or add a web interface or API to deploy it." in res.output
|
|
79
|
+
assert "no build minutes" in res.output
|
|
80
|
+
assert "Last build log lines" not in res.output and "Deployment failed" not in res.output
|
|
81
|
+
|
|
82
|
+
|
|
62
83
|
def test_deploy_no_wait(http, tmp_path):
|
|
63
84
|
root = make_project(tmp_path / "shop")
|
|
64
85
|
deploy_routes(http)
|
|
@@ -85,3 +106,46 @@ def test_mcp_print_config():
|
|
|
85
106
|
assert res.exit_code == 0
|
|
86
107
|
assert "claude mcp add dockhive -- hivepod mcp" in res.output
|
|
87
108
|
assert '"mcpServers"' in res.output and '"mcp"' in res.output
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------- Drone copy (user level)
|
|
112
|
+
|
|
113
|
+
def test_drone_internals_are_hidden_from_the_log():
|
|
114
|
+
from hivepods_cli import deploy as dp
|
|
115
|
+
log = [
|
|
116
|
+
"[drone] Reading your code…",
|
|
117
|
+
"[drone] Detected: Express (Node 22), port 3000",
|
|
118
|
+
"[drone] Wrote Dockerfile (multi-stage):",
|
|
119
|
+
" | FROM node:22-bookworm-slim AS build",
|
|
120
|
+
"#5 [build 1/4] FROM docker.io/library/node:22-bookworm-slim",
|
|
121
|
+
"[drone] Build failed: npm ERR! Missing script. Fixing (attempt 2/3)…",
|
|
122
|
+
"[drone] AI usage: 1.2k tokens",
|
|
123
|
+
"[drone] Build and health check passed",
|
|
124
|
+
"[drone] Missing environment variables: DB_URL. Add them in the project settings.",
|
|
125
|
+
"[drone] Deployed",
|
|
126
|
+
]
|
|
127
|
+
assert dp.filter_drone_log(log) == [
|
|
128
|
+
"[drone] Reading your code…",
|
|
129
|
+
"[drone] Building your app…",
|
|
130
|
+
"#5 [build 1/4] FROM docker.io/library/node:22-bookworm-slim",
|
|
131
|
+
"[drone] Found a problem, fixing it (attempt 2/3)…",
|
|
132
|
+
"[drone] Your app is running, going live…",
|
|
133
|
+
"[drone] Your app needs these environment variables: DB_URL",
|
|
134
|
+
"[drone] Deployed",
|
|
135
|
+
]
|
|
136
|
+
assert dp.status_label("health_check") == "Starting your app"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_drone_failure_message_is_user_level():
|
|
140
|
+
from hivepods_cli import deploy as dp
|
|
141
|
+
dep = {"deployment_id": "dep-1", "status": "failed",
|
|
142
|
+
"message": "Drone could not deploy this app after 3 attempt(s): health check failed: "
|
|
143
|
+
"no HTTP response on port 3000",
|
|
144
|
+
"drone": {"status": "failed", "attempts": 3, "summary": "An Express API.", "missing_env": []}}
|
|
145
|
+
out = dp.summarize(dep, [])
|
|
146
|
+
assert out["message"].startswith("Drone couldn't get this app live after 3 attempts.")
|
|
147
|
+
assert "port" not in out["message"] and "health" not in out["message"]
|
|
148
|
+
for key in ("dockerfile", "port", "health_path"):
|
|
149
|
+
assert key not in out["drone"]
|
|
150
|
+
plain = dict(dep, drone=None, message="git clone failed: not found")
|
|
151
|
+
assert dp.summarize(plain, [])["message"] == "git clone failed: not found"
|
|
@@ -275,6 +275,58 @@ def test_deploy_failure_returns_drone_details_and_log_tail(server, http, tmp_pat
|
|
|
275
275
|
assert "DATABASE_URL" in body["next_steps"][0]
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
NOT_DEPLOYABLE = {"status": "not_deployable", "attempts": 0, "summary": "", "missing_env": [],
|
|
279
|
+
"kind": "mobile_app",
|
|
280
|
+
"reason": "it has a Flutter pubspec.yaml and no web/ folder, so it builds iOS and Android apps",
|
|
281
|
+
"suggestion": "App hosting runs web apps and APIs. Deploy the app's backend, or its web "
|
|
282
|
+
"build (Flutter web, Expo web), by setting the Root directory to that folder.",
|
|
283
|
+
"suggested_root_directory": "backend"}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_deploy_not_deployable_is_a_structured_non_retryable_error(server, http, tmp_path):
|
|
287
|
+
root = make_project(tmp_path / "shop")
|
|
288
|
+
logs = ["[drone] Reading your code…",
|
|
289
|
+
"[drone] This looks like a Flutter mobile app: it has a Flutter pubspec.yaml",
|
|
290
|
+
"[drone] Not deployable as an app: App hosting runs web apps and APIs."]
|
|
291
|
+
deploy_routes(http, final="failed", logs=logs, drone=NOT_DEPLOYABLE)
|
|
292
|
+
init(server, "2025-06-18")
|
|
293
|
+
res = call(server, "deploy", {"path": str(root)})
|
|
294
|
+
assert res["isError"] is True
|
|
295
|
+
msg = text(res).split("\n\n", 1)[0]
|
|
296
|
+
assert msg.startswith("Drone can't deploy this: it looks like a mobile app, not a web service "
|
|
297
|
+
"(it has a Flutter pubspec.yaml")
|
|
298
|
+
assert "Not retryable" in msg
|
|
299
|
+
body = res["structuredContent"]
|
|
300
|
+
assert body["kind"] == "mobile_app" and body["reason"] == NOT_DEPLOYABLE["reason"]
|
|
301
|
+
assert body["suggestion"] == NOT_DEPLOYABLE["suggestion"] and body["retryable"] is False
|
|
302
|
+
assert body["not_deployable"]["suggested_root_directory"] == "backend"
|
|
303
|
+
assert "log_tail" not in body
|
|
304
|
+
assert body["next_steps"][0] == NOT_DEPLOYABLE["suggestion"]
|
|
305
|
+
assert any("`backend`" in s for s in body["next_steps"])
|
|
306
|
+
assert any("Don't retry" in s for s in body["next_steps"])
|
|
307
|
+
|
|
308
|
+
# deployment_status explains it the same way (not an error: it is a status query)
|
|
309
|
+
http.add("GET", DEP_PATH, {"deployment_id": DEP, "status": "failed", "drone": NOT_DEPLOYABLE,
|
|
310
|
+
"message": "Drone can't deploy this"})
|
|
311
|
+
http.add("GET", LOGS_PATH, {"deployment_id": DEP, "status": "failed", "lines": logs, "next": 3},
|
|
312
|
+
{"deployment_id": DEP, "status": "failed", "lines": [], "next": 3})
|
|
313
|
+
res = call(server, "deployment_status", {"deployment_id": DEP})
|
|
314
|
+
assert not res.get("isError")
|
|
315
|
+
assert res["structuredContent"]["summary"].startswith("Drone can't deploy this: it looks like a mobile app")
|
|
316
|
+
assert res["structuredContent"]["not_deployable"]["kind"] == "mobile_app"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_drone_info_ignores_verdict_fields_unless_not_deployable():
|
|
320
|
+
info = deploy_mod.drone_info({"drone": {"status": "failed", "attempts": 1, "summary": "", "missing_env": [],
|
|
321
|
+
"kind": None, "reason": None, "suggestion": None,
|
|
322
|
+
"suggested_root_directory": None}})
|
|
323
|
+
assert "kind" not in info and deploy_mod.not_deployable(info) is None
|
|
324
|
+
nd = deploy_mod.not_deployable(deploy_mod.drone_info({"drone": {"status": "not_deployable", "kind": 7}}))
|
|
325
|
+
assert nd == {"kind": "other", "reason": "", "suggestion": "", "suggested_root_directory": None}
|
|
326
|
+
assert deploy_mod.not_deployable_text(nd) == \
|
|
327
|
+
"Drone can't deploy this: it looks like something other than a web service, not a web service."
|
|
328
|
+
|
|
329
|
+
|
|
278
330
|
def test_deploy_errors(server, http, tmp_path):
|
|
279
331
|
root = make_project(tmp_path / "shop")
|
|
280
332
|
http.add("POST", UPLOAD, (413, {"error": "archive too large"}))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|