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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitfred = gitfred_cli.main:app
@@ -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