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.
Files changed (27) hide show
  1. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/PKG-INFO +13 -8
  2. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/README.md +12 -7
  3. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/__init__.py +1 -1
  4. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/client.py +27 -1
  5. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/deploy.py +146 -5
  6. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/main.py +25 -15
  7. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/mcp_server.py +38 -17
  8. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/PKG-INFO +13 -8
  9. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/pyproject.toml +1 -1
  10. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_client.py +32 -0
  11. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_deploy_cmd.py +64 -0
  12. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_mcp.py +52 -0
  13. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/__main__.py +0 -0
  14. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/browser_login.py +0 -0
  15. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/config.py +0 -0
  16. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/packaging.py +0 -0
  17. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli/terminal.py +0 -0
  18. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/SOURCES.txt +0 -0
  19. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/dependency_links.txt +0 -0
  20. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/entry_points.txt +0 -0
  21. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/requires.txt +0 -0
  22. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/hivepods_cli.egg-info/top_level.txt +0 -0
  23. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/setup.cfg +0 -0
  24. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_browser_login.py +0 -0
  25. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_config.py +0 -0
  26. {hivepods_cli-0.2.0 → hivepods_cli-0.2.1}/tests/test_packaging.py +0 -0
  27. {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.0
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 PORT=3000 --unset DEBUG # change environment variables
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 runs it
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
- No Dockerfile or config needed: **Drone**, DockHive's AI deploy agent, reads your code,
116
- works out how to build and run it, and fixes the build if the first try fails. You see
117
- Drone's notes as it works, then the live URL. Deploying the same folder again updates the
118
- same app (`--new` makes a separate one).
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, missing env vars and the build log tail on failure |
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 PORT=3000 --unset DEBUG # change environment variables
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 runs it
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
- No Dockerfile or config needed: **Drone**, DockHive's AI deploy agent, reads your code,
92
- works out how to build and run it, and fixes the build if the first try fails. You see
93
- Drone's notes as it works, then the live URL. Deploying the same folder again updates the
94
- same app (`--new` makes a separate one).
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, missing env vars and the build log tail on failure |
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 |
@@ -6,4 +6,4 @@ import warnings
6
6
  # noise for CLI users. Filtered before anything imports urllib3.
7
7
  warnings.filterwarnings("ignore", message=r"urllib3 v2 only supports OpenSSL")
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.2.1"
@@ -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("Permission denied for this resource.", 403, _json_or_none(resp))
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"The app needs environment variables: {names}. Deploy again "
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"The app needs environment variables: {names}. Call set_env "
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}\nTry `hivepod login --no-browser` to sign in with email and password.")
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
- _DEPLOY_LABELS = {
798
- "queued": "Queued", "uploading": "Uploading", "extracting": "Unpacking",
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 = _DEPLOY_LABELS.get(d.get("status"), d.get("status") or "Working")
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("attempts", 0) > 1:
872
- label += f" (attempt {drone['attempts']})"
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]Missing environment variables:[/yellow] " + ", ".join(drone["missing_env"])
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, works out how to build and run the app; no Dockerfile or \
50
- config is needed. The tool waits until the app is live and returns its URL; on failure \
51
- it returns Drone's summary, any missing environment variables and the end of the build \
52
- log. To update an app, deploy the same folder again (it is linked to the app it created) \
53
- or pass `project_id`. Put configuration in `env`; local .env files and keys are never \
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(f"status: {d.get('status')}")
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
- out["summary"] = f"Deployment {out['status']}." + (f" Drone: {drone['summary']}" if drone.get("summary") else "")
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: zero config, no Dockerfile "
529
- "needed. Give `path` (the project folder, absolute path; defaults to the server's "
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
- "missing env vars, last 60 build-log lines).",
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\") or \"dockerfile\" uses that preset. Existing apps keep their setting unless given."},
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, missing env vars) and, if it failed, "
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 (cloning, building, Drone's `[drone]` notes, health "
624
- "check). Returns the last `tail` lines.",
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.0
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 PORT=3000 --unset DEBUG # change environment variables
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 runs it
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
- No Dockerfile or config needed: **Drone**, DockHive's AI deploy agent, reads your code,
116
- works out how to build and run it, and fixes the build if the first try fails. You see
117
- Drone's notes as it works, then the live URL. Deploying the same folder again updates the
118
- same app (`--new` makes a separate one).
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, missing env vars and the build log tail on failure |
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hivepods-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Command line interface for DockHive / HivePods"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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