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.
- gitfred-0.1.0/.gitignore +38 -0
- gitfred-0.1.0/PKG-INFO +25 -0
- gitfred-0.1.0/README.md +14 -0
- gitfred-0.1.0/pyproject.toml +37 -0
- gitfred-0.1.0/src/gitfred_cli/__init__.py +1 -0
- gitfred-0.1.0/src/gitfred_cli/client.py +130 -0
- gitfred-0.1.0/src/gitfred_cli/commands/__init__.py +0 -0
- gitfred-0.1.0/src/gitfred_cli/commands/addon.py +38 -0
- gitfred-0.1.0/src/gitfred_cli/commands/app.py +149 -0
- gitfred-0.1.0/src/gitfred_cli/commands/auth.py +91 -0
- gitfred-0.1.0/src/gitfred_cli/commands/deploy.py +142 -0
- gitfred-0.1.0/src/gitfred_cli/commands/env.py +68 -0
- gitfred-0.1.0/src/gitfred_cli/commands/repo.py +61 -0
- gitfred-0.1.0/src/gitfred_cli/config.py +46 -0
- gitfred-0.1.0/src/gitfred_cli/helpers.py +60 -0
- gitfred-0.1.0/src/gitfred_cli/main.py +64 -0
- gitfred-0.1.0/src/gitfred_cli/output.py +54 -0
- gitfred-0.1.0/src/gitfred_cli/state.py +47 -0
- gitfred-0.1.0/tests/conftest.py +18 -0
- gitfred-0.1.0/tests/test_client.py +83 -0
- gitfred-0.1.0/tests/test_commands.py +228 -0
- gitfred-0.1.0/uv.lock +302 -0
gitfred-0.1.0/.gitignore
ADDED
|
@@ -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
|
gitfred-0.1.0/README.md
ADDED
|
@@ -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)
|