dooers-cli 0.7.0__tar.gz → 0.7.2__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.
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/PKG-INFO +2 -2
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/pyproject.toml +2 -2
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/agent_store.py +26 -3
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/agents.py +32 -0
- dooers_cli-0.7.2/src/dooers/cli/env_prod.py +31 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/ignore.py +2 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/manifest_sync.py +8 -3
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/push.py +9 -3
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/push_client.py +14 -1
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/validate.py +32 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_agent_store.py +21 -0
- dooers_cli-0.7.2/tests/test_env_prod.py +19 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_manifest_sync.py +9 -3
- dooers_cli-0.7.2/tests/test_push_failure_display.py +44 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_validate.py +45 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/uv.lock +2 -2
- dooers_cli-0.7.0/tests/test_push_failure_display.py +0 -22
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/.gitignore +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/README.md +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/__init__.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/auth.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/cli.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/config.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/core_client.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/org.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/settings.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/token_store.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/cli/user_config.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/src/dooers/py.typed +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/__init__.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_agents_delete.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_config.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_core_client.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_ignore.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_org.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_push_client_async.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_push_hosting_guard.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_push_poll.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_push_teardown.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_smoke.py +0 -0
- {dooers_cli-0.7.0 → dooers_cli-0.7.2}/tests/test_token_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dooers-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal
|
|
5
5
|
Project-URL: Repository, https://github.com/Dooers-ai/dooers-cli
|
|
6
6
|
Author-email: Dooers <dev@dooers.ai>
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
|
-
Requires-Dist: dooers-protocol>=0.6.
|
|
18
|
+
Requires-Dist: dooers-protocol>=0.6.1
|
|
19
19
|
Requires-Dist: httpx>=0.28.1
|
|
20
20
|
Requires-Dist: pydantic>=2.13.2
|
|
21
21
|
Requires-Dist: pyyaml>=6.0.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dooers-cli"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.2"
|
|
4
4
|
description = "Dooers CLI — push agents to Dooers, manage agent records, authenticate from the terminal"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
"pydantic>=2.13.2",
|
|
27
27
|
"pyyaml>=6.0.2",
|
|
28
28
|
"tqdm>=4.66.4",
|
|
29
|
-
"dooers-protocol>=0.6.
|
|
29
|
+
"dooers-protocol>=0.6.1",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[tool.uv.sources]
|
|
@@ -11,12 +11,26 @@ class AgentStoreError(RuntimeError):
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _data(resp: httpx.Response):
|
|
14
|
-
body
|
|
14
|
+
# The body may not be JSON: framework-level errors (e.g. a 403 "Forbidden")
|
|
15
|
+
# and empty success bodies (204) are common. Never let json() crash the CLI.
|
|
16
|
+
try:
|
|
17
|
+
body = resp.json()
|
|
18
|
+
except (json.JSONDecodeError, ValueError):
|
|
19
|
+
body = None
|
|
20
|
+
|
|
15
21
|
if isinstance(body, dict) and body.get("success") is False:
|
|
16
22
|
raise AgentStoreError(body.get("error", {}).get("message", f"HTTP {resp.status_code}"))
|
|
17
23
|
if resp.status_code >= 400:
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
detail = ""
|
|
25
|
+
if isinstance(body, dict):
|
|
26
|
+
err = body.get("error")
|
|
27
|
+
if isinstance(err, dict):
|
|
28
|
+
detail = err.get("message", "")
|
|
29
|
+
detail = detail or body.get("message", "")
|
|
30
|
+
detail = detail or (resp.text or "").strip()
|
|
31
|
+
msg = f"HTTP {resp.status_code}"
|
|
32
|
+
raise AgentStoreError(f"{msg}: {detail}" if detail else msg)
|
|
33
|
+
return body.get("data", body) if isinstance(body, dict) else body
|
|
20
34
|
|
|
21
35
|
|
|
22
36
|
def _record(d: dict) -> AgentRecord:
|
|
@@ -27,6 +41,7 @@ def _record(d: dict) -> AgentRecord:
|
|
|
27
41
|
organization_id=d.get("organizationId"),
|
|
28
42
|
host_url=d.get("hostUrl"),
|
|
29
43
|
status=d.get("status"),
|
|
44
|
+
runtime_api_key=d.get("runtimeApiKey"),
|
|
30
45
|
)
|
|
31
46
|
|
|
32
47
|
|
|
@@ -77,3 +92,11 @@ class HTTPCoreAgentStore:
|
|
|
77
92
|
f"{self.api}/agents/{agent_id}", headers=self._h(), timeout=self._timeout
|
|
78
93
|
)
|
|
79
94
|
_data(r) # success body is {success, message} with no data key — do NOT call _record()
|
|
95
|
+
|
|
96
|
+
def regenerate_runtime_api_key(self, agent_id: str) -> AgentRecord:
|
|
97
|
+
r = httpx.post(
|
|
98
|
+
f"{self.api}/agents/{agent_id}/runtime-api-key/regenerate",
|
|
99
|
+
headers=self._h(),
|
|
100
|
+
timeout=self._timeout,
|
|
101
|
+
)
|
|
102
|
+
return _record(_data(r))
|
|
@@ -15,6 +15,7 @@ from dooers.protocol.teardown import format_teardown_result
|
|
|
15
15
|
|
|
16
16
|
from dooers.cli import config
|
|
17
17
|
from dooers.cli.agent_store import AgentStoreError, HTTPCoreAgentStore
|
|
18
|
+
from dooers.cli.env_prod import upsert_agent_seed_secret
|
|
18
19
|
from dooers.cli.org import resolve_org_for_cli
|
|
19
20
|
from dooers.cli.push_client import PushClient, PushClientError
|
|
20
21
|
from dooers.cli.settings import Settings
|
|
@@ -65,9 +66,40 @@ def create(
|
|
|
65
66
|
directory=Path.cwd(),
|
|
66
67
|
)
|
|
67
68
|
typer.echo(f"Created {rec.agent_id}. {config.MANIFEST_FILENAME} written.")
|
|
69
|
+
if rec.runtime_api_key:
|
|
70
|
+
env_path = upsert_agent_seed_secret(Path.cwd(), rec.runtime_api_key)
|
|
71
|
+
typer.echo(f"Wrote AGENT_SEED_SECRET to {env_path.name} (shown once — store it safely).")
|
|
72
|
+
else:
|
|
73
|
+
typer.echo(
|
|
74
|
+
"Warning: core did not return a runtime API key. "
|
|
75
|
+
"Set Agent access key in Studio and AGENT_SEED_SECRET in env.prod before push.",
|
|
76
|
+
err=True,
|
|
77
|
+
)
|
|
68
78
|
typer.echo("Edit dooers.yaml (message_path, whatsapp, profile) then run dooers push.")
|
|
69
79
|
|
|
70
80
|
|
|
81
|
+
@app.command("rotate-key")
|
|
82
|
+
def rotate_key(
|
|
83
|
+
ctx: typer.Context,
|
|
84
|
+
agent_id: str = typer.Argument(..., help="Agent id to rotate the runtime API key for."),
|
|
85
|
+
) -> None:
|
|
86
|
+
store, _, _ = _store(ctx)
|
|
87
|
+
try:
|
|
88
|
+
rec = store.regenerate_runtime_api_key(agent_id)
|
|
89
|
+
except AgentStoreError as e:
|
|
90
|
+
typer.echo(f"rotate-key failed: {e}", err=True)
|
|
91
|
+
raise typer.Exit(code=1) from e
|
|
92
|
+
if rec.runtime_api_key:
|
|
93
|
+
env_path = upsert_agent_seed_secret(Path.cwd(), rec.runtime_api_key)
|
|
94
|
+
typer.echo(f"Wrote AGENT_SEED_SECRET to {env_path.name} (shown once — store it safely).")
|
|
95
|
+
else:
|
|
96
|
+
typer.echo(
|
|
97
|
+
"Warning: core did not return a runtime API key. Check Studio for the new key.",
|
|
98
|
+
err=True,
|
|
99
|
+
)
|
|
100
|
+
typer.echo("Run dooers push to deploy the updated secret to your agent host.")
|
|
101
|
+
|
|
102
|
+
|
|
71
103
|
@app.command(name="list")
|
|
72
104
|
def list_agents(ctx: typer.Context, org: str | None = typer.Option(None, "--org")) -> None:
|
|
73
105
|
store, settings, _ = _store(ctx)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Helpers for maintaining env.prod alongside dooers push."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_ENV_PROD = "env.prod"
|
|
9
|
+
_SEED_LINE = re.compile(r"^AGENT_SEED_SECRET=.*$", re.MULTILINE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def upsert_agent_seed_secret(directory: Path, secret: str) -> Path:
|
|
13
|
+
"""Write or replace AGENT_SEED_SECRET in env.prod (creates file if missing)."""
|
|
14
|
+
path = directory / _ENV_PROD
|
|
15
|
+
line = f"AGENT_SEED_SECRET={secret.strip()}\n"
|
|
16
|
+
if path.is_file():
|
|
17
|
+
text = path.read_text(encoding="utf-8")
|
|
18
|
+
if _SEED_LINE.search(text):
|
|
19
|
+
text = _SEED_LINE.sub(line.rstrip("\n"), text)
|
|
20
|
+
else:
|
|
21
|
+
if text and not text.endswith("\n"):
|
|
22
|
+
text += "\n"
|
|
23
|
+
text += "\n# Runtime API key (core ↔ agent settings.seed)\n" + line
|
|
24
|
+
path.write_text(text, encoding="utf-8")
|
|
25
|
+
else:
|
|
26
|
+
path.write_text(
|
|
27
|
+
"# Production runtime env (uploaded by dooers push; never commit secrets to git)\n"
|
|
28
|
+
+ line,
|
|
29
|
+
encoding="utf-8",
|
|
30
|
+
)
|
|
31
|
+
return path
|
|
@@ -15,11 +15,16 @@ def _norm_path(p: str) -> str:
|
|
|
15
15
|
def build_agent_patch(manifest: AgentManifest, deployed_url: str) -> dict:
|
|
16
16
|
"""Map the declarative manifest to a core v2 PATCH /agents/:id body.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Records the deployed URL as ``hostUrl`` and derives
|
|
19
|
+
serverConfig.apiMessagesUrl (and whatsapp inbound) from the deployed host +
|
|
20
|
+
the declared paths. Only includes keys the creator set.
|
|
21
|
+
|
|
22
|
+
The CLI writes ``hostUrl`` here (with the creator's token) because the async
|
|
23
|
+
push completion runs in a Pub/Sub webhook that has no core credentials, so
|
|
24
|
+
dooers-push can no longer write it back itself.
|
|
20
25
|
"""
|
|
21
26
|
host = _host_and_seg(deployed_url)
|
|
22
|
-
patch: dict = {}
|
|
27
|
+
patch: dict = {"hostUrl": deployed_url}
|
|
23
28
|
|
|
24
29
|
# Only sync a non-empty description. An empty/absent one means "leave as-is"
|
|
25
30
|
# — never send null, which would wipe a description set elsewhere (e.g. Studio).
|
|
@@ -17,7 +17,12 @@ from dooers.protocol.push import (
|
|
|
17
17
|
|
|
18
18
|
from dooers.cli import config, ignore
|
|
19
19
|
from dooers.cli.manifest_sync import build_agent_patch
|
|
20
|
-
from dooers.cli.push_client import
|
|
20
|
+
from dooers.cli.push_client import (
|
|
21
|
+
PushClient,
|
|
22
|
+
PushClientError,
|
|
23
|
+
PushTransientError,
|
|
24
|
+
friendly_push_error,
|
|
25
|
+
)
|
|
21
26
|
from dooers.cli.settings import Settings
|
|
22
27
|
from dooers.cli.token_store import TokenStore, is_token_expired
|
|
23
28
|
|
|
@@ -153,7 +158,8 @@ def push(
|
|
|
153
158
|
agent_id=agent_id, archive_path=archive_path, tag=tag, env=target_env
|
|
154
159
|
)
|
|
155
160
|
except PushClientError as e:
|
|
156
|
-
|
|
161
|
+
code = e.envelope.error_code if e.envelope else None
|
|
162
|
+
typer.echo(f"Push failed: {friendly_push_error(code, str(e))}", err=True)
|
|
157
163
|
raise typer.Exit(code=1) from e
|
|
158
164
|
finally:
|
|
159
165
|
os.unlink(archive_path)
|
|
@@ -170,7 +176,7 @@ def push(
|
|
|
170
176
|
typer.echo("\nAudit: 0 endpoints detected.")
|
|
171
177
|
|
|
172
178
|
# Poll until terminal, relabeling the spinner with the current phase.
|
|
173
|
-
spinner_state: dict[str,
|
|
179
|
+
spinner_state: dict[str, object] = {}
|
|
174
180
|
|
|
175
181
|
def _start_spinner(label: str) -> None:
|
|
176
182
|
cancel = spinner_state.get("cancel")
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
|
-
from dooers.protocol.errors import ErrorEnvelope
|
|
6
|
+
from dooers.protocol.errors import ErrorCode, ErrorEnvelope
|
|
7
7
|
from dooers.protocol.push import BuildStatusResponse, PushAcceptedResponse
|
|
8
8
|
from dooers.protocol.teardown import TeardownResponse
|
|
9
9
|
|
|
@@ -11,6 +11,19 @@ from dooers.protocol.teardown import TeardownResponse
|
|
|
11
11
|
_TRANSIENT_STATUS = frozenset({502, 503, 504})
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def friendly_push_error(error_code: object, message: str) -> str:
|
|
15
|
+
"""Map a server error code to a CLI-friendly message; fall back to `message`.
|
|
16
|
+
|
|
17
|
+
`error_code` may be an `ErrorCode` (a str enum) or a plain string.
|
|
18
|
+
"""
|
|
19
|
+
if error_code == ErrorCode.org_not_provisioned.value:
|
|
20
|
+
return (
|
|
21
|
+
"Your organization isn't set up for agent hosting yet. "
|
|
22
|
+
"Contact Dooers to enable hosting for your organization."
|
|
23
|
+
)
|
|
24
|
+
return message
|
|
25
|
+
|
|
26
|
+
|
|
14
27
|
class PushClientError(RuntimeError):
|
|
15
28
|
def __init__(self, message: str, envelope: ErrorEnvelope | None = None) -> None:
|
|
16
29
|
super().__init__(message)
|
|
@@ -93,6 +93,38 @@ def collect_issues(root: Path | None = None) -> list[ValidationIssue]:
|
|
|
93
93
|
)
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
+
for env_path in [base / ".env", *base.glob("env.*")]:
|
|
97
|
+
if not env_path.is_file():
|
|
98
|
+
continue
|
|
99
|
+
for raw in env_path.read_text().splitlines():
|
|
100
|
+
line = raw.strip()
|
|
101
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
102
|
+
continue
|
|
103
|
+
key, _, value = line.partition("=")
|
|
104
|
+
if key.strip().upper() in {
|
|
105
|
+
"AGENT_DATABASE_HOST",
|
|
106
|
+
"DATABASE_HOST",
|
|
107
|
+
"POSTGRES_HOST",
|
|
108
|
+
} and value.strip().lower() in {"127.0.0.1", "localhost", "0.0.0.0"}:
|
|
109
|
+
issues.append(
|
|
110
|
+
ValidationIssue(
|
|
111
|
+
"warning",
|
|
112
|
+
f"{env_path.name} sets {key.strip()} to localhost — "
|
|
113
|
+
"hosted deploys need a cloud database configured in Dooers Studio",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
manifest = read_manifest(directory=base) if manifest_path.exists() else None
|
|
118
|
+
if manifest is not None and manifest.hosting and not (base / "env.prod").is_file():
|
|
119
|
+
issues.append(
|
|
120
|
+
ValidationIssue(
|
|
121
|
+
"warning",
|
|
122
|
+
"hosting is enabled but env.prod is missing — "
|
|
123
|
+
"create env.prod with production runtime variables (OPENAI_API_KEY, "
|
|
124
|
+
"AGENT_DATABASE_*, etc.) for Cloud Run deploys",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
96
128
|
return issues
|
|
97
129
|
|
|
98
130
|
|
|
@@ -99,3 +99,24 @@ def test_get_populates_status():
|
|
|
99
99
|
)
|
|
100
100
|
rec = HTTPCoreAgentStore(BASE, "tok").get(A)
|
|
101
101
|
assert rec.status == "active"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@respx.mock
|
|
105
|
+
def test_delete_nonjson_403_raises_clean_error_not_jsondecode():
|
|
106
|
+
# Core returns a framework-level 403 with a plain-text body (not JSON).
|
|
107
|
+
respx.delete(f"{BASE}/api/v2/agents/{A}").mock(
|
|
108
|
+
return_value=httpx.Response(403, text="Forbidden")
|
|
109
|
+
)
|
|
110
|
+
store = HTTPCoreAgentStore(BASE, "tok")
|
|
111
|
+
with pytest.raises(AgentStoreError) as ei:
|
|
112
|
+
store.delete(A)
|
|
113
|
+
msg = str(ei.value)
|
|
114
|
+
assert "403" in msg and "Forbidden" in msg # clean message, no JSONDecodeError
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@respx.mock
|
|
118
|
+
def test_delete_empty_success_body_does_not_crash():
|
|
119
|
+
# A 204/empty success body must not blow up json() parsing.
|
|
120
|
+
respx.delete(f"{BASE}/api/v2/agents/{A}").mock(return_value=httpx.Response(204))
|
|
121
|
+
store = HTTPCoreAgentStore(BASE, "tok")
|
|
122
|
+
store.delete(A) # should simply return without raising
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from dooers.cli.env_prod import upsert_agent_seed_secret
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_upsert_creates_env_prod(tmp_path: Path) -> None:
|
|
7
|
+
path = upsert_agent_seed_secret(tmp_path, "secret-one-time-key-abc")
|
|
8
|
+
assert path.name == "env.prod"
|
|
9
|
+
assert path.read_text(encoding="utf-8").strip().endswith("AGENT_SEED_SECRET=secret-one-time-key-abc")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_upsert_replaces_existing_line(tmp_path: Path) -> None:
|
|
13
|
+
env = tmp_path / "env.prod"
|
|
14
|
+
env.write_text("FOO=1\nAGENT_SEED_SECRET=old\nBAR=2\n", encoding="utf-8")
|
|
15
|
+
upsert_agent_seed_secret(tmp_path, "new-key-xxxxxxxxxxxx")
|
|
16
|
+
text = env.read_text(encoding="utf-8")
|
|
17
|
+
assert "AGENT_SEED_SECRET=new-key-xxxxxxxxxxxx" in text
|
|
18
|
+
assert "AGENT_SEED_SECRET=old" not in text
|
|
19
|
+
assert "FOO=1" in text
|
|
@@ -166,14 +166,20 @@ def test_description_empty_string_not_in_patch():
|
|
|
166
166
|
|
|
167
167
|
|
|
168
168
|
# ---------------------------------------------------------------------------
|
|
169
|
-
#
|
|
169
|
+
# hostUrl — always recorded (the CLI writes it; the async webhook can't)
|
|
170
170
|
# ---------------------------------------------------------------------------
|
|
171
171
|
|
|
172
172
|
|
|
173
|
-
def
|
|
173
|
+
def test_minimal_manifest_records_host_url_only():
|
|
174
174
|
m = _manifest()
|
|
175
175
|
patch = build_agent_patch(m, DEPLOYED)
|
|
176
|
-
assert patch == {}
|
|
176
|
+
assert patch == {"hostUrl": DEPLOYED}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_host_url_always_present_alongside_other_fields():
|
|
180
|
+
m = _manifest(message_path="/agent", description="A helpful agent")
|
|
181
|
+
patch = build_agent_patch(m, DEPLOYED)
|
|
182
|
+
assert patch["hostUrl"] == DEPLOYED
|
|
177
183
|
|
|
178
184
|
|
|
179
185
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Push failure display tests."""
|
|
2
|
+
|
|
3
|
+
from dooers.protocol.errors import ErrorCode
|
|
4
|
+
from dooers.protocol.push import BuildStatus, PushResponse, format_push_failure
|
|
5
|
+
|
|
6
|
+
from dooers.cli.push_client import friendly_push_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_friendly_push_error_org_not_provisioned() -> None:
|
|
10
|
+
msg = friendly_push_error(ErrorCode.org_not_provisioned, "raw server message")
|
|
11
|
+
assert "set up for agent hosting" in msg.lower()
|
|
12
|
+
assert "raw server message" not in msg
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_friendly_push_error_accepts_plain_string_code() -> None:
|
|
16
|
+
assert "hosting" in friendly_push_error("org_not_provisioned", "x").lower()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_friendly_push_error_falls_back_to_message() -> None:
|
|
20
|
+
assert friendly_push_error(ErrorCode.forbidden, "you do not own this agent") == (
|
|
21
|
+
"you do not own this agent"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_format_push_failure_includes_step_and_logs() -> None:
|
|
26
|
+
response = PushResponse(
|
|
27
|
+
agent_id="agent-1",
|
|
28
|
+
build_id="build-123",
|
|
29
|
+
image="img",
|
|
30
|
+
status=BuildStatus.failed,
|
|
31
|
+
error="docker build failed: missing Dockerfile",
|
|
32
|
+
failed_step="build image",
|
|
33
|
+
build_log_url="https://console.cloud.google.com/cloud-build/builds/build-123?project=proj",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
text = format_push_failure(response)
|
|
37
|
+
|
|
38
|
+
assert "Deployment failed" in text
|
|
39
|
+
assert "Failed during: build image" in text
|
|
40
|
+
assert "docker build failed" in text
|
|
41
|
+
# Console URL (embeds the project id) must NOT be surfaced; a sanitized
|
|
42
|
+
# build reference is shown instead. See de1b80c.
|
|
43
|
+
assert "Build logs:" not in text
|
|
44
|
+
assert "Reference: build build-123" in text
|
|
@@ -80,3 +80,48 @@ organization_id: "org_1"
|
|
|
80
80
|
|
|
81
81
|
issues = collect_issues(tmp_path)
|
|
82
82
|
assert any("legacy SDK imports" in i.message for i in issues)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_validate_warns_on_localhost_database_host(tmp_path: Path) -> None:
|
|
86
|
+
_write(
|
|
87
|
+
tmp_path / "dooers.yaml",
|
|
88
|
+
"""
|
|
89
|
+
protocol_version: "2"
|
|
90
|
+
agent_id: "550e8400-e29b-41d4-a716-446655440000"
|
|
91
|
+
name: test
|
|
92
|
+
organization_id: "org_1"
|
|
93
|
+
hosting: true
|
|
94
|
+
""".strip()
|
|
95
|
+
+ "\n",
|
|
96
|
+
)
|
|
97
|
+
_write(tmp_path / "Dockerfile", "FROM python:3.12-slim\n")
|
|
98
|
+
_write(
|
|
99
|
+
tmp_path / "pyproject.toml",
|
|
100
|
+
'[project]\ndependencies = ["dooers-agents-server>=0.12.0"]\n',
|
|
101
|
+
)
|
|
102
|
+
_write(tmp_path / ".env", "AGENT_DATABASE_HOST=127.0.0.1\n")
|
|
103
|
+
|
|
104
|
+
issues = collect_issues(tmp_path)
|
|
105
|
+
assert any("localhost" in i.message.lower() for i in issues)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_validate_warns_when_hosting_without_env_prod(tmp_path: Path) -> None:
|
|
109
|
+
_write(
|
|
110
|
+
tmp_path / "dooers.yaml",
|
|
111
|
+
"""
|
|
112
|
+
protocol_version: "2"
|
|
113
|
+
agent_id: "550e8400-e29b-41d4-a716-446655440000"
|
|
114
|
+
name: test
|
|
115
|
+
organization_id: "org_1"
|
|
116
|
+
hosting: true
|
|
117
|
+
""".strip()
|
|
118
|
+
+ "\n",
|
|
119
|
+
)
|
|
120
|
+
_write(tmp_path / "Dockerfile", "FROM python:3.12-slim\n")
|
|
121
|
+
_write(
|
|
122
|
+
tmp_path / "pyproject.toml",
|
|
123
|
+
'[project]\ndependencies = ["dooers-agents-server>=0.12.0"]\n',
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
issues = collect_issues(tmp_path)
|
|
127
|
+
assert any("env.prod is missing" in i.message for i in issues)
|
|
@@ -232,7 +232,7 @@ toml = [
|
|
|
232
232
|
|
|
233
233
|
[[package]]
|
|
234
234
|
name = "dooers-cli"
|
|
235
|
-
version = "0.7.
|
|
235
|
+
version = "0.7.2"
|
|
236
236
|
source = { editable = "." }
|
|
237
237
|
dependencies = [
|
|
238
238
|
{ name = "dooers-protocol" },
|
|
@@ -278,7 +278,7 @@ provides-extras = ["dev"]
|
|
|
278
278
|
|
|
279
279
|
[[package]]
|
|
280
280
|
name = "dooers-protocol"
|
|
281
|
-
version = "0.6.
|
|
281
|
+
version = "0.6.1"
|
|
282
282
|
source = { editable = "../dooers-protocol" }
|
|
283
283
|
dependencies = [
|
|
284
284
|
{ name = "pydantic" },
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Push failure display tests."""
|
|
2
|
-
|
|
3
|
-
from dooers.protocol.push import BuildStatus, PushResponse, format_push_failure
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_format_push_failure_includes_step_and_logs() -> None:
|
|
7
|
-
response = PushResponse(
|
|
8
|
-
agent_id="agent-1",
|
|
9
|
-
build_id="build-123",
|
|
10
|
-
image="img",
|
|
11
|
-
status=BuildStatus.failed,
|
|
12
|
-
error="docker build failed: missing Dockerfile",
|
|
13
|
-
failed_step="build image",
|
|
14
|
-
build_log_url="https://console.cloud.google.com/cloud-build/builds/build-123?project=proj",
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
text = format_push_failure(response)
|
|
18
|
-
|
|
19
|
-
assert "Deployment failed" in text
|
|
20
|
-
assert "Failed during: build image" in text
|
|
21
|
-
assert "docker build failed" in text
|
|
22
|
-
assert "Build logs:" in text
|
|
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
|
|
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
|