gitfred 0.1.0__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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
10
+
11
+ # Env / secrets
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ *.pem
16
+ kubeconfig*
17
+ *.kubeconfig
18
+
19
+ # Node / frontend
20
+ node_modules/
21
+ frontend/dist/
22
+ frontend/.vite/
23
+ *.local
24
+
25
+ # Editor / OS
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
29
+ *.swp
30
+
31
+ # Build artifacts
32
+ dist/
33
+ build/
34
+ .gitfred/
35
+
36
+ # Sample validation repos (each is its own GitHub repo, staged here)
37
+ /samples/*
38
+ !/samples/VALIDATION.md
gitfred-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitfred
3
+ Version: 0.1.0
4
+ Summary: GitFred CLI — deploy GitHub repositories as live web apps from your terminal.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: rich>=13.0
9
+ Requires-Dist: typer>=0.15
10
+ Description-Content-Type: text/markdown
11
+
12
+ # gitfred
13
+
14
+ The GitFred CLI: deploy GitHub repositories as live web apps at `https://<slug>.gitfred.com` from your terminal — or from an AI coding agent.
15
+
16
+ ```bash
17
+ uv tool install gitfred # or: pipx install gitfred
18
+ gitfred login # browser-approval sign-in
19
+ gitfred app create --repo you/your-repo --publish
20
+ gitfred deploy 42 --follow
21
+ ```
22
+
23
+ Every command supports `--json` for machine-readable output and uses meaningful exit codes (`0` ok, `2` usage, `3` auth, `4` not found, `5` quota, `6` deploy failed, `7` timeout), so agents can drive it non-interactively with `GITFRED_TOKEN`.
24
+
25
+ Docs: https://app.gitfred.com/docs/cli
@@ -0,0 +1,14 @@
1
+ # gitfred
2
+
3
+ The GitFred CLI: deploy GitHub repositories as live web apps at `https://<slug>.gitfred.com` from your terminal — or from an AI coding agent.
4
+
5
+ ```bash
6
+ uv tool install gitfred # or: pipx install gitfred
7
+ gitfred login # browser-approval sign-in
8
+ gitfred app create --repo you/your-repo --publish
9
+ gitfred deploy 42 --follow
10
+ ```
11
+
12
+ Every command supports `--json` for machine-readable output and uses meaningful exit codes (`0` ok, `2` usage, `3` auth, `4` not found, `5` quota, `6` deploy failed, `7` timeout), so agents can drive it non-interactively with `GITFRED_TOKEN`.
13
+
14
+ Docs: https://app.gitfred.com/docs/cli
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "gitfred"
3
+ version = "0.1.0"
4
+ description = "GitFred CLI — deploy GitHub repositories as live web apps from your terminal."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "typer>=0.15",
10
+ "httpx>=0.27",
11
+ "rich>=13.0",
12
+ ]
13
+
14
+ [project.scripts]
15
+ gitfred = "gitfred_cli.main:app"
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/gitfred_cli"]
23
+
24
+ [dependency-groups]
25
+ dev = ["pytest>=8.3"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ addopts = "-q"
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B"]
37
+ ignore = ["E501", "B008"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """HTTP client for the GitFred /api/v1 contract, plus the SSE log iterator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from . import __version__
12
+ from .output import EXIT_AUTH, EXIT_ERROR, EXIT_NOT_FOUND, EXIT_QUOTA
13
+
14
+
15
+ class ApiError(Exception):
16
+ def __init__(self, status: int, message: str, detail: Any = None):
17
+ super().__init__(message)
18
+ self.status = status
19
+ self.detail = detail
20
+
21
+ @property
22
+ def exit_code(self) -> int:
23
+ if self.status in (401, 403):
24
+ return EXIT_AUTH
25
+ if self.status == 404:
26
+ return EXIT_NOT_FOUND
27
+ if self.status == 429:
28
+ return EXIT_QUOTA
29
+ return EXIT_ERROR
30
+
31
+
32
+ class Client:
33
+ """Thin wrapper around httpx bound to ``{api_url}/api/v1`` with bearer auth."""
34
+
35
+ def __init__(
36
+ self,
37
+ api_url: str,
38
+ token: str | None = None,
39
+ timeout: float = 30.0,
40
+ transport: httpx.BaseTransport | None = None,
41
+ ):
42
+ headers = {"User-Agent": f"gitfred-cli/{__version__}"}
43
+ if token:
44
+ headers["Authorization"] = f"Bearer {token}"
45
+ self._http = httpx.Client(
46
+ base_url=api_url.rstrip("/") + "/api/v1",
47
+ headers=headers,
48
+ timeout=timeout,
49
+ transport=transport,
50
+ )
51
+
52
+ def request(
53
+ self,
54
+ method: str,
55
+ path: str,
56
+ *,
57
+ body: Any = None,
58
+ params: dict | None = None,
59
+ expect_json: bool = True,
60
+ ) -> Any:
61
+ try:
62
+ res = self._http.request(method, path, json=body, params=params)
63
+ except httpx.HTTPError as exc:
64
+ raise ApiError(0, f"cannot reach the GitFred API: {exc}") from exc
65
+ if res.status_code >= 400:
66
+ raise self._error(res)
67
+ if not expect_json or res.status_code == 204 or not res.content:
68
+ return None
69
+ return res.json()
70
+
71
+ def get(self, path: str, **kw: Any) -> Any:
72
+ return self.request("GET", path, **kw)
73
+
74
+ def post(self, path: str, **kw: Any) -> Any:
75
+ return self.request("POST", path, **kw)
76
+
77
+ def put(self, path: str, **kw: Any) -> Any:
78
+ return self.request("PUT", path, **kw)
79
+
80
+ def patch(self, path: str, **kw: Any) -> Any:
81
+ return self.request("PATCH", path, **kw)
82
+
83
+ def delete(self, path: str, **kw: Any) -> Any:
84
+ return self.request("DELETE", path, **kw)
85
+
86
+ def status_of(self, method: str, path: str, *, body: Any = None) -> int:
87
+ """Like request() but returns the status code, never raising on 4xx.
88
+ Used by the device-auth poll loop where 202 is a normal answer."""
89
+ res = self._http.request(method, path, json=body)
90
+ if res.status_code >= 400:
91
+ raise self._error(res)
92
+ return res.status_code
93
+
94
+ def sse(self, path: str, params: dict | None = None) -> Iterator[tuple[str, dict]]:
95
+ """Yield ``(event, data)`` pairs from an SSE endpoint until it closes."""
96
+ with self._http.stream(
97
+ "GET", path, params=params, timeout=httpx.Timeout(30.0, read=90.0)
98
+ ) as res:
99
+ if res.status_code >= 400:
100
+ res.read()
101
+ raise self._error(res)
102
+ event = "message"
103
+ data_lines: list[str] = []
104
+ for line in res.iter_lines():
105
+ if line.startswith("event:"):
106
+ event = line[6:].strip()
107
+ elif line.startswith("data:"):
108
+ data_lines.append(line[5:].strip())
109
+ elif line == "" and data_lines:
110
+ try:
111
+ yield event, json.loads("\n".join(data_lines))
112
+ except ValueError:
113
+ pass
114
+ event = "message"
115
+ data_lines = []
116
+
117
+ @staticmethod
118
+ def _error(res: httpx.Response) -> ApiError:
119
+ message = f"{res.status_code} {res.reason_phrase}"
120
+ detail: Any = None
121
+ try:
122
+ payload = res.json()
123
+ detail = payload.get("detail", payload)
124
+ if isinstance(detail, str):
125
+ message = detail
126
+ elif isinstance(detail, dict) and "message" in detail:
127
+ message = str(detail["message"])
128
+ except ValueError:
129
+ pass
130
+ return ApiError(res.status_code, message, detail)
File without changes
@@ -0,0 +1,38 @@
1
+ """addon enable — turn on platform addons for an app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ..helpers import handle_errors, resolve_app
8
+ from ..output import emit
9
+ from ..state import get_state
10
+
11
+ addon_app = typer.Typer(help="Manage application addons (postgres, valkey, volume, object-storage).")
12
+
13
+ _KINDS = {
14
+ "postgres": "enable_postgres",
15
+ "valkey": "enable_valkey",
16
+ "volume": "enable_volume",
17
+ "object-storage": "enable_object_storage",
18
+ "object_storage": "enable_object_storage",
19
+ }
20
+
21
+
22
+ @addon_app.command("enable")
23
+ @handle_errors
24
+ def enable(
25
+ app_ref: str = typer.Argument(..., metavar="APP"),
26
+ kind: str = typer.Argument(..., help="postgres | valkey | volume | object-storage"),
27
+ ) -> None:
28
+ """Enable an addon (provisioned on the next deploy)."""
29
+ field = _KINDS.get(kind.lower())
30
+ if field is None:
31
+ raise typer.BadParameter(f"unknown addon {kind!r} (expected one of: {', '.join(sorted(set(_KINDS) - {'object_storage'}))})")
32
+ data = resolve_app(app_ref)
33
+ updated = get_state().client.patch(f"/applications/{data['id']}", body={field: True})
34
+ emit(
35
+ updated,
36
+ f"Enabled [bold]{kind}[/bold] on {updated['name']} — redeploy to provision: "
37
+ f"gitfred deploy {updated['id']}",
38
+ )
@@ -0,0 +1,149 @@
1
+ """app create / list / show / delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ from ..helpers import handle_errors, resolve_app, resolve_repository
11
+ from ..output import EXIT_ERROR, console, emit, fail, json_mode
12
+ from ..state import get_state
13
+
14
+ app_app = typer.Typer(help="Manage applications.")
15
+
16
+ ADDON_FLAGS = ("postgres", "valkey", "volume", "object_storage")
17
+
18
+
19
+ def _parse_env(pairs: list[str]) -> dict[str, str]:
20
+ env: dict[str, str] = {}
21
+ for pair in pairs:
22
+ if "=" not in pair:
23
+ raise fail(f"--env expects KEY=VALUE, got {pair!r}", 2)
24
+ key, value = pair.split("=", 1)
25
+ env[key] = value
26
+ return env
27
+
28
+
29
+ @app_app.command("create")
30
+ @handle_errors
31
+ def create(
32
+ repo: str = typer.Option(..., "--repo", help="owner/name or a connected repository id."),
33
+ name: str | None = typer.Option(None, "--name", help="Application name (default: repo name)."),
34
+ branch: str | None = typer.Option(None, "--branch"),
35
+ port: int | None = typer.Option(None, "--port"),
36
+ health_path: str | None = typer.Option(None, "--health-path"),
37
+ postgres: bool = typer.Option(False, "--postgres", help="Enable the PostgreSQL addon."),
38
+ valkey: bool = typer.Option(False, "--valkey", help="Enable the Valkey (Redis) addon."),
39
+ volume: bool = typer.Option(False, "--volume", help="Enable the persistent-volume addon."),
40
+ object_storage: bool = typer.Option(
41
+ False, "--object-storage", help="Enable the S3 object-storage addon."
42
+ ),
43
+ env: list[str] = typer.Option([], "--env", help="KEY=VALUE (repeatable)."),
44
+ auto_deploy: bool = typer.Option(False, "--auto-deploy", help="Republish on every push."),
45
+ no_detect: bool = typer.Option(
46
+ False, "--no-detect", help="Skip repo-type detection and its suggested defaults."
47
+ ),
48
+ publish: bool = typer.Option(False, "--publish", help="Trigger the first deploy immediately."),
49
+ ) -> None:
50
+ """Create an application from a repository (detection fills in sensible defaults)."""
51
+ client = get_state().client
52
+ repository = resolve_repository(repo)
53
+
54
+ detection = None
55
+ if not no_detect:
56
+ detection = client.post(
57
+ "/applications/detect",
58
+ body={"repository_id": repository["id"], "branch": branch},
59
+ )
60
+ if not detection["deployable"]:
61
+ raise fail(
62
+ f"{repository['full_name']} is not deployable: "
63
+ f"{detection.get('reason') or detection['label']}",
64
+ EXIT_ERROR,
65
+ detail=detection,
66
+ )
67
+ if not json_mode():
68
+ console.print(
69
+ f"Detected [bold]{detection['label']}[/bold]"
70
+ + (f" · {detection['note']}" if detection.get("note") else "")
71
+ )
72
+
73
+ suggestions = (detection or {}).get("suggestions", {})
74
+ body = {
75
+ "repository_id": repository["id"],
76
+ "name": name or repository["name"],
77
+ "branch": branch,
78
+ "port": port if port is not None else suggestions.get("port", 8080),
79
+ "health_path": health_path
80
+ if health_path is not None
81
+ else suggestions.get("health_path", "/"),
82
+ "release_command": suggestions.get("release_command"),
83
+ "auto_deploy": auto_deploy,
84
+ "enable_postgres": postgres or suggestions.get("enable_postgres", False),
85
+ "enable_valkey": valkey or suggestions.get("enable_valkey", False),
86
+ "enable_volume": volume,
87
+ "enable_object_storage": object_storage
88
+ or suggestions.get("enable_object_storage", False),
89
+ "env": _parse_env(env),
90
+ }
91
+ created = client.post("/applications", body=body)
92
+
93
+ deployment = None
94
+ if publish:
95
+ deployment = client.post(f"/applications/{created['id']}/publish")
96
+
97
+ emit(
98
+ {"application": created, "deployment": deployment, "detection": detection},
99
+ f"Created application [bold]{created['name']}[/bold] (id {created['id']})"
100
+ + (f"\nDeployment {deployment['id']} queued — follow with: gitfred logs {deployment['id']} --follow"
101
+ if deployment else
102
+ f"\nDeploy it with: gitfred deploy {created['id']}"),
103
+ )
104
+
105
+
106
+ @app_app.command("list")
107
+ @handle_errors
108
+ def list_apps() -> None:
109
+ """List your applications."""
110
+ apps = get_state().client.get("/applications")
111
+ emit(apps)
112
+ if not json_mode():
113
+ table = Table(header_style="bold")
114
+ for col in ("id", "name", "status", "url", "last deployed"):
115
+ table.add_column(col)
116
+ for a in apps:
117
+ table.add_row(
118
+ str(a["id"]), a["name"], a["status"], a.get("url") or "-",
119
+ a.get("last_deployed_at") or "never",
120
+ )
121
+ console.print(table)
122
+
123
+
124
+ @app_app.command("show")
125
+ @handle_errors
126
+ def show(app_ref: str = typer.Argument(..., metavar="APP", help="Application id, slug, or name.")) -> None:
127
+ """Show one application in full."""
128
+ data = resolve_app(app_ref)
129
+ emit(data)
130
+ if not json_mode():
131
+ console.print_json(data=data)
132
+
133
+
134
+ @app_app.command("delete")
135
+ @handle_errors
136
+ def delete(
137
+ app_ref: str = typer.Argument(..., metavar="APP"),
138
+ yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
139
+ ) -> None:
140
+ """Delete an application and tear down everything it provisioned."""
141
+ data = resolve_app(app_ref)
142
+ if not yes:
143
+ if not sys.stdin.isatty():
144
+ raise fail("refusing to delete without --yes in non-interactive mode", 2)
145
+ typer.confirm(
146
+ f"Delete '{data['name']}' (id {data['id']}) and all its resources?", abort=True
147
+ )
148
+ get_state().client.delete(f"/applications/{data['id']}")
149
+ emit({"status": "deleted", "id": data["id"]}, f"Deleted application {data['id']}.")
@@ -0,0 +1,91 @@
1
+ """login / logout / whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import time
7
+ import webbrowser
8
+
9
+ import typer
10
+
11
+ from ..client import ApiError, Client
12
+ from ..config import load_config, save_config
13
+ from ..helpers import handle_errors
14
+ from ..output import EXIT_AUTH, EXIT_TIMEOUT, console, emit, fail, json_mode
15
+ from ..state import get_state
16
+
17
+
18
+ @handle_errors
19
+ def login(
20
+ token: str | None = typer.Option(
21
+ None, "--token", help="Use an existing API token instead of the browser flow."
22
+ ),
23
+ no_browser: bool = typer.Option(
24
+ False, "--no-browser", help="Print the approval URL instead of opening a browser."
25
+ ),
26
+ ) -> None:
27
+ """Sign in to GitFred (opens your browser to approve this device)."""
28
+ state = get_state()
29
+ if token is None and not json_mode():
30
+ token = _device_flow(state.anon_client, open_browser=not no_browser)
31
+ elif token is None:
32
+ # --json implies non-interactive: a polling browser flow makes no sense there.
33
+ raise fail("in --json mode pass --token or set GITFRED_TOKEN", EXIT_AUTH)
34
+
35
+ me = Client(state.api_url, token).get("/auth/me")
36
+ config = load_config()
37
+ config["token"] = token
38
+ config["api_url"] = state.api_url
39
+ save_config(config)
40
+ emit(me, f"Signed in as [bold]@{me['login']}[/bold]")
41
+
42
+
43
+ def _device_flow(client: Client, open_browser: bool) -> str:
44
+ started = client.post(
45
+ "/auth/device",
46
+ body={"client_name": "gitfred-cli", "client_host": socket.gethostname()},
47
+ )
48
+ url = started["verification_url"]
49
+ console.print(f"Approve this device in your browser:\n\n [bold cyan]{url}[/bold cyan]\n")
50
+ if open_browser:
51
+ webbrowser.open(url)
52
+ deadline = time.monotonic() + started["expires_in"]
53
+ interval = started.get("interval", 2)
54
+ console.print("Waiting for approval…")
55
+ while time.monotonic() < deadline:
56
+ try:
57
+ result = client.post("/auth/device/poll", body={"device_code": started["device_code"]})
58
+ except ApiError as exc:
59
+ if exc.status == 403:
60
+ raise fail("authorization was denied in the browser", EXIT_AUTH) from exc
61
+ raise
62
+ if result is not None:
63
+ return result["token"]
64
+ time.sleep(interval)
65
+ raise fail("login timed out — run `gitfred login` again", EXIT_TIMEOUT)
66
+
67
+
68
+ @handle_errors
69
+ def logout() -> None:
70
+ """Sign out: revoke this device's token and remove it from the local config."""
71
+ config = load_config()
72
+ token = config.pop("token", None)
73
+ if token:
74
+ # Best-effort server-side revocation (the PAT can revoke itself).
75
+ try:
76
+ client = get_state().client
77
+ for t in client.get("/tokens"):
78
+ if token.startswith(t["token_prefix"]):
79
+ client.delete(f"/tokens/{t['id']}")
80
+ break
81
+ except ApiError:
82
+ pass
83
+ save_config(config)
84
+ emit({"status": "logged_out"}, "Signed out.")
85
+
86
+
87
+ @handle_errors
88
+ def whoami() -> None:
89
+ """Show the signed-in user."""
90
+ me = get_state().client.get("/auth/me")
91
+ emit(me, f"@{me['login']} ({me.get('name') or 'no name'}) · user id {me['id']}")
@@ -0,0 +1,142 @@
1
+ """deploy / logs / open — the publish-and-watch path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import webbrowser
7
+
8
+ import typer
9
+
10
+ from ..client import ApiError
11
+ from ..helpers import handle_errors, resolve_app
12
+ from ..output import (
13
+ EXIT_DEPLOY_FAILED,
14
+ EXIT_OK,
15
+ EXIT_TIMEOUT,
16
+ console,
17
+ emit,
18
+ fail,
19
+ json_mode,
20
+ )
21
+ from ..state import get_state
22
+
23
+ _TERMINAL = {"succeeded", "failed", "canceled"}
24
+
25
+
26
+ @handle_errors
27
+ def deploy(
28
+ app_ref: str = typer.Argument(..., metavar="APP", help="Application id, slug, or name."),
29
+ follow: bool = typer.Option(
30
+ True, "--follow/--no-follow", help="Stream build/deploy logs until the deploy finishes."
31
+ ),
32
+ timeout: int = typer.Option(1800, "--timeout", help="Max seconds to wait with --follow."),
33
+ ) -> None:
34
+ """Publish an application (build + deploy the latest commit)."""
35
+ client = get_state().client
36
+ data = resolve_app(app_ref)
37
+ deployment = client.post(f"/applications/{data['id']}/publish")
38
+ if not follow:
39
+ emit(
40
+ deployment,
41
+ f"Deployment {deployment['id']} queued — follow with: "
42
+ f"gitfred logs {deployment['id']} --follow",
43
+ )
44
+ return
45
+ if not json_mode():
46
+ console.print(f"Deployment [bold]{deployment['id']}[/bold] queued, streaming logs…")
47
+ status = _follow_logs(deployment["id"], timeout)
48
+ final = client.get(f"/deployments/{deployment['id']}")
49
+ emit(final, None)
50
+ _finish(final, status)
51
+
52
+
53
+ @handle_errors
54
+ def logs(
55
+ deployment_id: int = typer.Argument(..., help="Deployment id."),
56
+ follow: bool = typer.Option(False, "--follow", help="Stream until the deploy finishes."),
57
+ after: int = typer.Option(0, "--after", help="Only lines with seq > AFTER."),
58
+ timeout: int = typer.Option(1800, "--timeout", help="Max seconds to wait with --follow."),
59
+ ) -> None:
60
+ """Print (or stream) the logs of a deployment."""
61
+ client = get_state().client
62
+ if not follow:
63
+ lines = client.get(f"/deployments/{deployment_id}/logs", params={"after": after})
64
+ emit(lines)
65
+ if not json_mode():
66
+ for entry in lines:
67
+ _print_line(entry)
68
+ return
69
+ status = _follow_logs(deployment_id, timeout, after=after)
70
+ final = client.get(f"/deployments/{deployment_id}")
71
+ emit(final, None)
72
+ _finish(final, status)
73
+
74
+
75
+ @handle_errors
76
+ def open_app(app_ref: str = typer.Argument(..., metavar="APP")) -> None:
77
+ """Open the application's live URL in your browser."""
78
+ data = resolve_app(app_ref)
79
+ url = data.get("url")
80
+ if not url:
81
+ raise fail("application has no URL yet — deploy it first", 4)
82
+ emit({"url": url}, f"Opening [bold cyan]{url}[/bold cyan]")
83
+ if not json_mode():
84
+ webbrowser.open(url)
85
+
86
+
87
+ def _print_line(entry: dict) -> None:
88
+ stage = entry.get("stage", "")
89
+ line = entry.get("line", "")
90
+ if entry.get("stream") == "stderr":
91
+ console.print(f"[dim]{stage:>10}[/dim] [red]{line}[/red]", highlight=False)
92
+ else:
93
+ console.print(f"[dim]{stage:>10}[/dim] {line}", highlight=False)
94
+
95
+
96
+ def _follow_logs(deployment_id: int, timeout: int, after: int = 0) -> str | None:
97
+ """Stream logs via SSE (falling back to polling) until terminal or timeout.
98
+
99
+ Returns the final status when the stream reported it, else None.
100
+ """
101
+ client = get_state().client
102
+ deadline = time.monotonic() + timeout
103
+ last_seq = after
104
+ while time.monotonic() < deadline:
105
+ try:
106
+ for event, data in client.sse(
107
+ f"/deployments/{deployment_id}/logs/stream", params={"after": last_seq}
108
+ ):
109
+ if event == "log":
110
+ last_seq = max(last_seq, data.get("seq", last_seq))
111
+ if not json_mode():
112
+ _print_line(data)
113
+ elif event == "done":
114
+ return data.get("status")
115
+ if time.monotonic() > deadline:
116
+ raise fail("timed out waiting for the deployment", EXIT_TIMEOUT)
117
+ # Stream closed without a `done` event (proxy idle cut) — check and resume.
118
+ except ApiError:
119
+ # SSE unavailable (old proxy, flaky link) — fall back to one polling round.
120
+ time.sleep(2)
121
+ entries = client.get(
122
+ f"/deployments/{deployment_id}/logs", params={"after": last_seq}
123
+ )
124
+ for entry in entries:
125
+ last_seq = max(last_seq, entry["seq"])
126
+ if not json_mode():
127
+ _print_line(entry)
128
+ status = client.get(f"/deployments/{deployment_id}")["status"]
129
+ if status in _TERMINAL:
130
+ return status
131
+ raise fail("timed out waiting for the deployment", EXIT_TIMEOUT)
132
+
133
+
134
+ def _finish(final: dict, status: str | None) -> None:
135
+ status = status or final.get("status")
136
+ if status == "succeeded":
137
+ if not json_mode():
138
+ console.print(f"\n[green]✓ deployed[/green] {final.get('url') or ''}")
139
+ raise SystemExit(EXIT_OK)
140
+ if not json_mode():
141
+ console.print(f"\n[red]✗ deployment {status}[/red] {final.get('error') or ''}")
142
+ raise SystemExit(EXIT_DEPLOY_FAILED)