gitfred 0.1.0__py3-none-any.whl
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.dist-info/METADATA +25 -0
- gitfred-0.1.0.dist-info/RECORD +18 -0
- gitfred-0.1.0.dist-info/WHEEL +4 -0
- gitfred-0.1.0.dist-info/entry_points.txt +2 -0
- gitfred_cli/__init__.py +1 -0
- gitfred_cli/client.py +130 -0
- gitfred_cli/commands/__init__.py +0 -0
- gitfred_cli/commands/addon.py +38 -0
- gitfred_cli/commands/app.py +149 -0
- gitfred_cli/commands/auth.py +91 -0
- gitfred_cli/commands/deploy.py +142 -0
- gitfred_cli/commands/env.py +68 -0
- gitfred_cli/commands/repo.py +61 -0
- gitfred_cli/config.py +46 -0
- gitfred_cli/helpers.py +60 -0
- gitfred_cli/main.py +64 -0
- gitfred_cli/output.py +54 -0
- gitfred_cli/state.py +47 -0
|
@@ -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,18 @@
|
|
|
1
|
+
gitfred_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
gitfred_cli/client.py,sha256=fvTH6iljCqTiXIxGPKwznTTFHps2VGpgNcpEOZLsIQg,4380
|
|
3
|
+
gitfred_cli/config.py,sha256=hI_cSHpkDZ5Af8rDPpYv_cV8vwLmzRX94dFgH5-9-jQ,1189
|
|
4
|
+
gitfred_cli/helpers.py,sha256=s0yzzxUfth2Nc_7lxhjpNbL3YapBUTMsbvqPTMGOUBw,2019
|
|
5
|
+
gitfred_cli/main.py,sha256=9ySm02WQxgT0Ib5z_80Ee501oiAbMGoL5MW2eT0rhVk,1891
|
|
6
|
+
gitfred_cli/output.py,sha256=h1DAlVRGlOMJHXHx93gBNpyww2qSSPRMc5G2wt_YcOo,1356
|
|
7
|
+
gitfred_cli/state.py,sha256=GjP1rBWSZ7MlNMVJ4AkghIEVH--m27Q7HMHHB6cwSGo,1258
|
|
8
|
+
gitfred_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
gitfred_cli/commands/addon.py,sha256=ZDgiz7gSLXZJbGOjz4myPzbMIqDz7mtHlutBjJKqJyg,1274
|
|
10
|
+
gitfred_cli/commands/app.py,sha256=UqGE_BPsfGt0wcduQz62uk7NyGq3plWXLYj1tE0tz_k,5750
|
|
11
|
+
gitfred_cli/commands/auth.py,sha256=ADNHvttJy-qRQMVwnbKOMDPJKS7Mp2FT8ot8vc7qTHE,3158
|
|
12
|
+
gitfred_cli/commands/deploy.py,sha256=k_fzOBmtlG13W6GigQWDZWlduIGCdkVtVPAl9M22Cjg,5175
|
|
13
|
+
gitfred_cli/commands/env.py,sha256=d6MWM_zSglBrtpXUl8nX4ULHsfQEegT45zYq2cpcoxA,2322
|
|
14
|
+
gitfred_cli/commands/repo.py,sha256=o8Kv011wIXgRfRAmRfluxNmEhvYZ1C_wwLFijNVVRAs,2043
|
|
15
|
+
gitfred-0.1.0.dist-info/METADATA,sha256=w5ixnmN2PHH5ulkNQqGh0Wo1oJpN7aJVQHXgIgyG2Ao,943
|
|
16
|
+
gitfred-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
17
|
+
gitfred-0.1.0.dist-info/entry_points.txt,sha256=SXqT2IdmiWZ6zxW9CsOtCMZAM3Cpr39fC2pHAcnAUk4,49
|
|
18
|
+
gitfred-0.1.0.dist-info/RECORD,,
|
gitfred_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
gitfred_cli/client.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""env get / set / unset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..helpers import handle_errors, resolve_app
|
|
8
|
+
from ..output import console, emit, fail, json_mode
|
|
9
|
+
from ..state import get_state
|
|
10
|
+
|
|
11
|
+
env_app = typer.Typer(help="Manage application environment variables.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_items(app_id: int) -> list[dict]:
|
|
15
|
+
return get_state().client.get(f"/applications/{app_id}/env")["items"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _put_items(app_id: int, items: dict[str, str]) -> list[dict]:
|
|
19
|
+
body = {"items": [{"key": k, "value": v} for k, v in items.items()]}
|
|
20
|
+
return get_state().client.put(f"/applications/{app_id}/env", body=body)["items"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@env_app.command("get")
|
|
24
|
+
@handle_errors
|
|
25
|
+
def get(app_ref: str = typer.Argument(..., metavar="APP")) -> None:
|
|
26
|
+
"""Print the application's environment variables."""
|
|
27
|
+
data = resolve_app(app_ref)
|
|
28
|
+
items = _get_items(data["id"])
|
|
29
|
+
emit(items)
|
|
30
|
+
if not json_mode():
|
|
31
|
+
for item in items:
|
|
32
|
+
console.print(f"{item['key']}={item['value']}", highlight=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@env_app.command("set")
|
|
36
|
+
@handle_errors
|
|
37
|
+
def set_(
|
|
38
|
+
app_ref: str = typer.Argument(..., metavar="APP"),
|
|
39
|
+
pairs: list[str] = typer.Argument(..., metavar="KEY=VALUE..."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Set one or more variables (redeploy to apply)."""
|
|
42
|
+
data = resolve_app(app_ref)
|
|
43
|
+
merged = {i["key"]: i["value"] for i in _get_items(data["id"])}
|
|
44
|
+
for pair in pairs:
|
|
45
|
+
if "=" not in pair:
|
|
46
|
+
raise fail(f"expected KEY=VALUE, got {pair!r}", 2)
|
|
47
|
+
key, value = pair.split("=", 1)
|
|
48
|
+
merged[key] = value
|
|
49
|
+
items = _put_items(data["id"], merged)
|
|
50
|
+
emit(items, f"Set {len(pairs)} variable(s). Redeploy to apply: gitfred deploy {data['id']}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@env_app.command("unset")
|
|
54
|
+
@handle_errors
|
|
55
|
+
def unset(
|
|
56
|
+
app_ref: str = typer.Argument(..., metavar="APP"),
|
|
57
|
+
keys: list[str] = typer.Argument(..., metavar="KEY..."),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Remove one or more variables (redeploy to apply)."""
|
|
60
|
+
data = resolve_app(app_ref)
|
|
61
|
+
merged = {i["key"]: i["value"] for i in _get_items(data["id"])}
|
|
62
|
+
missing = [k for k in keys if k not in merged]
|
|
63
|
+
if missing:
|
|
64
|
+
raise fail(f"not set: {', '.join(missing)}", 4)
|
|
65
|
+
for key in keys:
|
|
66
|
+
del merged[key]
|
|
67
|
+
items = _put_items(data["id"], merged)
|
|
68
|
+
emit(items, f"Removed {len(keys)} variable(s). Redeploy to apply: gitfred deploy {data['id']}")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""repo list / search / connect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ..helpers import handle_errors
|
|
9
|
+
from ..output import console, emit, json_mode
|
|
10
|
+
from ..state import get_state
|
|
11
|
+
|
|
12
|
+
repo_app = typer.Typer(help="Browse and connect GitHub repositories.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _repo_table(rows: list[dict], title: str) -> Table:
|
|
16
|
+
table = Table(title=title, header_style="bold")
|
|
17
|
+
table.add_column("id")
|
|
18
|
+
table.add_column("repository")
|
|
19
|
+
table.add_column("private")
|
|
20
|
+
table.add_column("default branch")
|
|
21
|
+
for r in rows:
|
|
22
|
+
table.add_row(
|
|
23
|
+
str(r.get("id") or r.get("github_repo_id")),
|
|
24
|
+
r["full_name"],
|
|
25
|
+
"yes" if r.get("private") else "no",
|
|
26
|
+
r.get("default_branch") or "-",
|
|
27
|
+
)
|
|
28
|
+
return table
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@repo_app.command("list")
|
|
32
|
+
@handle_errors
|
|
33
|
+
def list_repos(
|
|
34
|
+
github: bool = typer.Option(
|
|
35
|
+
False, "--github", help="List repositories visible on GitHub instead of connected ones."
|
|
36
|
+
),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""List connected repositories (or, with --github, your GitHub repositories)."""
|
|
39
|
+
client = get_state().client
|
|
40
|
+
rows = client.get("/repositories/github" if github else "/repositories")
|
|
41
|
+
emit(rows)
|
|
42
|
+
if not json_mode():
|
|
43
|
+
console.print(_repo_table(rows, "GitHub repositories" if github else "Connected repositories"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@repo_app.command("search")
|
|
47
|
+
@handle_errors
|
|
48
|
+
def search(q: str = typer.Argument(..., help="Search query (GitHub repo search syntax).")) -> None:
|
|
49
|
+
"""Search public GitHub repositories."""
|
|
50
|
+
rows = get_state().client.get("/repositories/github/search", params={"q": q})
|
|
51
|
+
emit(rows)
|
|
52
|
+
if not json_mode():
|
|
53
|
+
console.print(_repo_table(rows, f"Search: {q}"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@repo_app.command("connect")
|
|
57
|
+
@handle_errors
|
|
58
|
+
def connect(full_name: str = typer.Argument(..., help="owner/name")) -> None:
|
|
59
|
+
"""Connect a repository to GitFred."""
|
|
60
|
+
repo = get_state().client.post("/repositories", body={"full_name": full_name})
|
|
61
|
+
emit(repo, f"Connected [bold]{repo['full_name']}[/bold] (repository id {repo['id']})")
|
gitfred_cli/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Local CLI configuration: token + API URL.
|
|
2
|
+
|
|
3
|
+
Precedence for the token: ``GITFRED_TOKEN`` env > ``--token`` flag > config file.
|
|
4
|
+
The config file lives at ``~/.config/gitfred/config.json`` (0600).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://api.gitfred.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_path() -> Path:
|
|
17
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(Path.home(), ".config")
|
|
18
|
+
return Path(base) / "gitfred" / "config.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config() -> dict:
|
|
22
|
+
path = config_path()
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(path.read_text())
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_config(data: dict) -> None:
|
|
30
|
+
path = config_path()
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
33
|
+
path.chmod(0o600)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_token(flag_token: str | None) -> str | None:
|
|
37
|
+
return os.environ.get("GITFRED_TOKEN") or flag_token or load_config().get("token")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_api_url(flag_url: str | None) -> str:
|
|
41
|
+
return (
|
|
42
|
+
os.environ.get("GITFRED_API_URL")
|
|
43
|
+
or flag_url
|
|
44
|
+
or load_config().get("api_url")
|
|
45
|
+
or DEFAULT_API_URL
|
|
46
|
+
)
|
gitfred_cli/helpers.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared command plumbing: error handling and repo/app resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .client import ApiError
|
|
9
|
+
from .output import fail
|
|
10
|
+
from .state import get_state
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_errors(fn: Callable) -> Callable:
|
|
14
|
+
"""Map ApiError to the exit-code contract; everything else propagates."""
|
|
15
|
+
|
|
16
|
+
@functools.wraps(fn)
|
|
17
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
18
|
+
try:
|
|
19
|
+
return fn(*args, **kwargs)
|
|
20
|
+
except ApiError as exc:
|
|
21
|
+
raise fail(str(exc), exc.exit_code, detail=exc.detail) from exc
|
|
22
|
+
|
|
23
|
+
return wrapper
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_repository(repo: str) -> dict:
|
|
27
|
+
"""Resolve ``owner/name`` or a numeric connected-repo id to a Repository.
|
|
28
|
+
|
|
29
|
+
``owner/name`` is connected automatically when not already connected.
|
|
30
|
+
"""
|
|
31
|
+
client = get_state().client
|
|
32
|
+
connected = client.get("/repositories")
|
|
33
|
+
if repo.isdigit():
|
|
34
|
+
for r in connected:
|
|
35
|
+
if r["id"] == int(repo):
|
|
36
|
+
return r
|
|
37
|
+
raise fail(f"no connected repository with id {repo}", 4)
|
|
38
|
+
matches = [r for r in connected if r["full_name"].lower() == repo.lower()]
|
|
39
|
+
if matches:
|
|
40
|
+
return matches[0]
|
|
41
|
+
if "/" not in repo:
|
|
42
|
+
raise fail(f"expected owner/name or a repository id, got {repo!r}", 2)
|
|
43
|
+
return client.post("/repositories", body={"full_name": repo})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_app(app_ref: str) -> dict:
|
|
47
|
+
"""Resolve an application id, slug, or exact name to an ApplicationOut."""
|
|
48
|
+
client = get_state().client
|
|
49
|
+
if app_ref.isdigit():
|
|
50
|
+
return client.get(f"/applications/{app_ref}")
|
|
51
|
+
apps = client.get("/applications")
|
|
52
|
+
matches = [
|
|
53
|
+
a for a in apps if a.get("slug") == app_ref or a["name"].lower() == app_ref.lower()
|
|
54
|
+
]
|
|
55
|
+
if len(matches) == 1:
|
|
56
|
+
return matches[0]
|
|
57
|
+
if not matches:
|
|
58
|
+
raise fail(f"no application matching {app_ref!r}", 4)
|
|
59
|
+
ids = ", ".join(str(a["id"]) for a in matches)
|
|
60
|
+
raise fail(f"{app_ref!r} is ambiguous (ids: {ids}) — use the id", 2)
|
gitfred_cli/main.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""gitfred — deploy GitHub repositories as live web apps from your terminal.
|
|
2
|
+
|
|
3
|
+
Agent-friendly by design: every command takes --json (machine-readable output,
|
|
4
|
+
JSON errors on stderr), auth comes from GITFRED_TOKEN or `gitfred login`, and
|
|
5
|
+
exit codes are a stable contract (see output.py).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .commands.addon import addon_app
|
|
14
|
+
from .commands.app import app_app
|
|
15
|
+
from .commands.auth import login, logout, whoami
|
|
16
|
+
from .commands.deploy import deploy, logs, open_app
|
|
17
|
+
from .commands.env import env_app
|
|
18
|
+
from .commands.repo import repo_app
|
|
19
|
+
from .output import set_json_mode
|
|
20
|
+
from .state import set_state
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="gitfred",
|
|
24
|
+
help=__doc__,
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
pretty_exceptions_show_locals=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback(invoke_without_command=True)
|
|
32
|
+
def main(
|
|
33
|
+
json_output: bool = typer.Option(
|
|
34
|
+
False, "--json", help="Machine-readable JSON output (errors as JSON on stderr)."
|
|
35
|
+
),
|
|
36
|
+
token: str | None = typer.Option(
|
|
37
|
+
None, "--token", envvar="GITFRED_TOKEN", help="API token (overrides the saved login)."
|
|
38
|
+
),
|
|
39
|
+
api_url: str | None = typer.Option(
|
|
40
|
+
None, "--api-url", envvar="GITFRED_API_URL", help="GitFred API base URL."
|
|
41
|
+
),
|
|
42
|
+
version: bool = typer.Option(False, "--version", help="Print the version and exit."),
|
|
43
|
+
) -> None:
|
|
44
|
+
if version:
|
|
45
|
+
print(f"gitfred {__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
set_json_mode(json_output)
|
|
48
|
+
set_state(api_url, token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
app.command("login")(login)
|
|
52
|
+
app.command("logout")(logout)
|
|
53
|
+
app.command("whoami")(whoami)
|
|
54
|
+
app.command("deploy")(deploy)
|
|
55
|
+
app.command("logs")(logs)
|
|
56
|
+
app.command("open")(open_app)
|
|
57
|
+
app.add_typer(repo_app, name="repo")
|
|
58
|
+
app.add_typer(app_app, name="app")
|
|
59
|
+
app.add_typer(env_app, name="env")
|
|
60
|
+
app.add_typer(addon_app, name="addon")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
app()
|
gitfred_cli/output.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Rendering + exit-code conventions.
|
|
2
|
+
|
|
3
|
+
Exit codes (stable contract for scripts and agents):
|
|
4
|
+
0 success · 2 usage error · 3 auth · 4 not found · 5 quota/rate-limit ·
|
|
5
|
+
6 deploy failed · 7 timeout · 1 anything else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
EXIT_ERROR = 1
|
|
18
|
+
EXIT_USAGE = 2
|
|
19
|
+
EXIT_AUTH = 3
|
|
20
|
+
EXIT_NOT_FOUND = 4
|
|
21
|
+
EXIT_QUOTA = 5
|
|
22
|
+
EXIT_DEPLOY_FAILED = 6
|
|
23
|
+
EXIT_TIMEOUT = 7
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
err_console = Console(stderr=True)
|
|
27
|
+
|
|
28
|
+
_json_mode = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_json_mode(enabled: bool) -> None:
|
|
32
|
+
global _json_mode
|
|
33
|
+
_json_mode = enabled
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def json_mode() -> bool:
|
|
37
|
+
return _json_mode
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def emit(data: Any, human: str | None = None) -> None:
|
|
41
|
+
"""Print ``data`` as JSON in --json mode, else the human rendering (or nothing)."""
|
|
42
|
+
if _json_mode:
|
|
43
|
+
print(json.dumps(data, indent=2, default=str))
|
|
44
|
+
elif human is not None:
|
|
45
|
+
console.print(human)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fail(message: str, exit_code: int = EXIT_ERROR, detail: Any = None) -> "SystemExit":
|
|
49
|
+
"""Print an error (JSON on stderr in --json mode) and return SystemExit to raise."""
|
|
50
|
+
if _json_mode:
|
|
51
|
+
err_console.print_json(json.dumps({"error": message, "detail": detail}, default=str))
|
|
52
|
+
else:
|
|
53
|
+
err_console.print(f"[red]error:[/red] {message}")
|
|
54
|
+
return SystemExit(exit_code)
|
gitfred_cli/state.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Per-invocation CLI state, set by the root callback and read by commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .client import Client
|
|
8
|
+
from .config import resolve_api_url, resolve_token
|
|
9
|
+
from .output import EXIT_AUTH, fail
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class State:
|
|
14
|
+
api_url: str
|
|
15
|
+
token: str | None
|
|
16
|
+
|
|
17
|
+
_client: Client | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def client(self) -> Client:
|
|
21
|
+
"""Authenticated client; exits 3 when no token is configured."""
|
|
22
|
+
if self._client is None:
|
|
23
|
+
if not self.token:
|
|
24
|
+
raise fail(
|
|
25
|
+
"not signed in — run `gitfred login` or set GITFRED_TOKEN", EXIT_AUTH
|
|
26
|
+
)
|
|
27
|
+
self._client = Client(self.api_url, self.token)
|
|
28
|
+
return self._client
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def anon_client(self) -> Client:
|
|
32
|
+
"""Unauthenticated client (device-auth flow)."""
|
|
33
|
+
return Client(self.api_url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_state: State | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_state(api_url_flag: str | None, token_flag: str | None) -> State:
|
|
40
|
+
global _state
|
|
41
|
+
_state = State(api_url=resolve_api_url(api_url_flag), token=resolve_token(token_flag))
|
|
42
|
+
return _state
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_state() -> State:
|
|
46
|
+
assert _state is not None, "root callback did not run"
|
|
47
|
+
return _state
|