pyvolt-cli 0.0.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ .venv/
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyvolt-cli
3
+ Version: 0.2.0
4
+ Summary: The command-line companion to Pyvolt: managed Python deployments on infrastructure you actually own.
5
+ Project-URL: Homepage, https://pyvolt.com
6
+ Project-URL: Repository, https://github.com/pyvolt-hq/pyvolt-cli
7
+ Project-URL: Issues, https://github.com/pyvolt-hq/pyvolt-cli/issues
8
+ Author-email: Will Abbott <will@digitaloutback.co.uk>
9
+ Maintainer-email: Pyvolt HQ <will@digitaloutback.co.uk>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: deployment,digitalocean,django,fastapi,flask,hetzner,paas,python,vps
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Classifier: Topic :: System :: Installation/Setup
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: rich>=13.7
28
+ Requires-Dist: typer>=0.12
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pyvolt-cli
32
+
33
+ The command-line companion to [Pyvolt](https://pyvolt.com) — managed Python
34
+ deployments on infrastructure you actually own.
35
+
36
+ ```bash
37
+ uv tool install pyvolt-cli # or: pipx install pyvolt-cli
38
+ pyvolt login # browser approval, no password in the terminal
39
+ pyvolt deploy myapp --follow
40
+ ```
41
+
42
+ | Command | What it does |
43
+ |---|---|
44
+ | `pyvolt login` / `logout` / `whoami` | Device-flow auth (token stored in `~/.config/pyvolt/`, mode 600) |
45
+ | `pyvolt servers` / `pyvolt apps` | Inventory with live status |
46
+ | `pyvolt deploy DOMAIN [--follow]` | Trigger a deployment, optionally streaming the log |
47
+ | `pyvolt deployments DOMAIN` | Recent deployment history |
48
+ | `pyvolt env DOMAIN list\|set K=V…\|rm K` | Environment variables — pushed and app restarted |
49
+ | `pyvolt ps DOMAIN [restart NAME]` | Background processes with live systemd state |
50
+ | `pyvolt logs DOMAIN [-p NAME] [-n N]` | Tail the app's journal |
51
+ | `pyvolt bans SERVER` | IPs banned by fail2ban, your own flagged |
52
+ | `pyvolt unban SERVER [IP] [--me]` | Lift a ban — `--me` unbans your own IP, even while it's banned |
53
+ | `pyvolt open DOMAIN` | Jump to the dashboard |
54
+
55
+ `DOMAIN` is the app's domain, or any unambiguous fragment of it — domains
56
+ are unique across the platform, so no server qualifier is ever needed.
57
+
58
+ Full guide: [pyvolt.com/docs/cli](https://pyvolt.com/docs/cli/) · HTTP API:
59
+ [pyvolt.com/api/docs](https://pyvolt.com/api/docs)
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ uv sync
65
+ uv run pytest
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,40 @@
1
+ # pyvolt-cli
2
+
3
+ The command-line companion to [Pyvolt](https://pyvolt.com) — managed Python
4
+ deployments on infrastructure you actually own.
5
+
6
+ ```bash
7
+ uv tool install pyvolt-cli # or: pipx install pyvolt-cli
8
+ pyvolt login # browser approval, no password in the terminal
9
+ pyvolt deploy myapp --follow
10
+ ```
11
+
12
+ | Command | What it does |
13
+ |---|---|
14
+ | `pyvolt login` / `logout` / `whoami` | Device-flow auth (token stored in `~/.config/pyvolt/`, mode 600) |
15
+ | `pyvolt servers` / `pyvolt apps` | Inventory with live status |
16
+ | `pyvolt deploy DOMAIN [--follow]` | Trigger a deployment, optionally streaming the log |
17
+ | `pyvolt deployments DOMAIN` | Recent deployment history |
18
+ | `pyvolt env DOMAIN list\|set K=V…\|rm K` | Environment variables — pushed and app restarted |
19
+ | `pyvolt ps DOMAIN [restart NAME]` | Background processes with live systemd state |
20
+ | `pyvolt logs DOMAIN [-p NAME] [-n N]` | Tail the app's journal |
21
+ | `pyvolt bans SERVER` | IPs banned by fail2ban, your own flagged |
22
+ | `pyvolt unban SERVER [IP] [--me]` | Lift a ban — `--me` unbans your own IP, even while it's banned |
23
+ | `pyvolt open DOMAIN` | Jump to the dashboard |
24
+
25
+ `DOMAIN` is the app's domain, or any unambiguous fragment of it — domains
26
+ are unique across the platform, so no server qualifier is ever needed.
27
+
28
+ Full guide: [pyvolt.com/docs/cli](https://pyvolt.com/docs/cli/) · HTTP API:
29
+ [pyvolt.com/api/docs](https://pyvolt.com/api/docs)
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ uv sync
35
+ uv run pytest
36
+ ```
37
+
38
+ ## License
39
+
40
+ MIT. See [LICENSE](LICENSE).
@@ -4,16 +4,21 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyvolt-cli"
7
- version = "0.0.1"
8
- description = "The command-line companion to pyvolt: managed Python deployments on infrastructure you actually own."
7
+ version = "0.2.0"
8
+ description = "The command-line companion to Pyvolt: managed Python deployments on infrastructure you actually own."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
- requires-python = ">=3.10"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "typer>=0.12",
14
+ "httpx>=0.27",
15
+ "rich>=13.7",
16
+ ]
12
17
  authors = [
13
18
  { name = "Will Abbott", email = "will@digitaloutback.co.uk" },
14
19
  ]
15
20
  maintainers = [
16
- { name = "PyVolt HQ", email = "will@digitaloutback.co.uk" },
21
+ { name = "Pyvolt HQ", email = "will@digitaloutback.co.uk" },
17
22
  ]
18
23
  keywords = [
19
24
  "deployment",
@@ -27,13 +32,12 @@ keywords = [
27
32
  "digitalocean",
28
33
  ]
29
34
  classifiers = [
30
- "Development Status :: 1 - Planning",
35
+ "Development Status :: 4 - Beta",
31
36
  "Environment :: Console",
32
37
  "Intended Audience :: Developers",
33
38
  "License :: OSI Approved :: MIT License",
34
39
  "Operating System :: OS Independent",
35
40
  "Programming Language :: Python :: 3",
36
- "Programming Language :: Python :: 3.10",
37
41
  "Programming Language :: Python :: 3.11",
38
42
  "Programming Language :: Python :: 3.12",
39
43
  "Programming Language :: Python :: 3.13",
@@ -60,3 +64,9 @@ include = [
60
64
  "LICENSE",
61
65
  "pyproject.toml",
62
66
  ]
67
+
68
+ [dependency-groups]
69
+ dev = [
70
+ "pytest>=8",
71
+ "respx>=0.22",
72
+ ]
@@ -0,0 +1,4 @@
1
+ """pyvolt-cli: the terminal companion to Pyvolt — managed Python deployments
2
+ on infrastructure you actually own. Docs: https://pyvolt.com/docs/cli/"""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ """Entry point for the ``pyvolt`` command (``[project.scripts]``)."""
2
+ from __future__ import annotations
3
+
4
+ from .app import app
5
+
6
+
7
+ def main() -> None:
8
+ app()
9
+
10
+
11
+ if __name__ == "__main__": # pragma: no cover
12
+ main()
@@ -0,0 +1,355 @@
1
+ """The Pyvolt CLI — deploy, logs, env and processes from your terminal."""
2
+ from __future__ import annotations
3
+
4
+ import platform
5
+ import time
6
+ import webbrowser
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from . import __version__, config
13
+ from .client import Api, fail
14
+
15
+ app = typer.Typer(
16
+ name="pyvolt",
17
+ help="The Pyvolt CLI — manage servers, apps and deployments on your own infrastructure.",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ )
21
+ console = Console()
22
+
23
+ STATUS_STYLE = {
24
+ "ready": "green", "live": "green", "succeeded": "green", "running": "cyan",
25
+ "active": "green", "queued": "yellow", "deploying": "cyan",
26
+ "failed": "red", "error": "red", "inactive": "red",
27
+ }
28
+
29
+
30
+ def _status(s: str) -> str:
31
+ return f"[{STATUS_STYLE.get(s, 'white')}]{s}[/]"
32
+
33
+
34
+ def _table(*columns: str) -> Table:
35
+ t = Table(box=None, header_style="bold dim", pad_edge=False)
36
+ for c in columns:
37
+ t.add_column(c)
38
+ return t
39
+
40
+
41
+ @app.callback(invoke_without_command=True)
42
+ def _main(
43
+ ctx: typer.Context,
44
+ version: bool = typer.Option(False, "--version", "-V", help="Print version and exit."),
45
+ ):
46
+ if version:
47
+ console.print(f"pyvolt-cli {__version__}")
48
+ raise typer.Exit()
49
+ if ctx.invoked_subcommand is None:
50
+ console.print(ctx.get_help())
51
+ raise typer.Exit()
52
+
53
+
54
+ # ---- auth -------------------------------------------------------------------
55
+
56
+ @app.command()
57
+ def login():
58
+ """Authorize this machine via your browser (no password in the terminal)."""
59
+ import httpx
60
+
61
+ base = config.api_url()
62
+ try:
63
+ start = httpx.post(
64
+ f"{base}/api/cli/auth/start",
65
+ json={"device_name": platform.node()},
66
+ timeout=30,
67
+ ).json()
68
+ except httpx.HTTPError as e:
69
+ raise fail(f"Could not reach {base}: {e}") from e
70
+
71
+ console.print(f"\nConfirmation code: [bold cyan]{start['user_code']}[/]")
72
+ console.print(f"Opening [link]{start['verification_uri']}[/link] — approve there if the code matches.\n")
73
+ webbrowser.open(start["verification_uri"])
74
+
75
+ deadline = time.monotonic() + start.get("expires_in", 600)
76
+ with console.status("Waiting for browser approval…"):
77
+ while time.monotonic() < deadline:
78
+ time.sleep(start.get("interval", 2))
79
+ poll = httpx.post(
80
+ f"{base}/api/cli/auth/poll",
81
+ json={"device_code": start["device_code"]},
82
+ timeout=30,
83
+ ).json()
84
+ if poll["status"] == "approved":
85
+ path = config.save(poll["token"], base)
86
+ console.print(f"[green]✓[/green] Logged in — token stored in {path}")
87
+ return
88
+ if poll["status"] == "denied":
89
+ raise fail("Request denied in the browser.")
90
+ if poll["status"] == "expired":
91
+ raise fail("Login request expired — run [bold]pyvolt login[/bold] again.")
92
+ raise fail("Timed out waiting for approval.")
93
+
94
+
95
+ @app.command()
96
+ def logout():
97
+ """Remove this machine's stored token (revoke it fully under Account → API)."""
98
+ config.clear()
99
+ console.print("[green]✓[/green] Logged out. Revoke the token under Account → API on the dashboard.")
100
+
101
+
102
+ @app.command()
103
+ def whoami():
104
+ """Show the authenticated account."""
105
+ me = Api().get("/v1/me")
106
+ console.print(f"{me['username']} <{me['email']}> @ {config.api_url()}")
107
+
108
+
109
+ # ---- read-only inventory ------------------------------------------------------
110
+
111
+ @app.command()
112
+ def servers():
113
+ """List your servers."""
114
+ t = _table("NAME", "IP", "PROVIDER", "STATUS", "CONNECTED")
115
+ for s in Api().get("/v1/servers"):
116
+ t.add_row(
117
+ s["name"], s["ip_address"], s["provider"], _status(s["status"]),
118
+ "[green]yes[/]" if s["connected"] else "[red]no[/]",
119
+ )
120
+ console.print(t)
121
+
122
+
123
+ @app.command()
124
+ def ssh(
125
+ server: str = typer.Argument(..., metavar="SERVER"),
126
+ app_name: str = typer.Option("", "--app", "-a", help="cd into this app's directory on connect."),
127
+ ):
128
+ """Open an SSH shell on a server (as the pyvolt app-user).
129
+
130
+ A thin convenience wrapper: it resolves the host/port from the API and
131
+ execs your local `ssh`, so your own key must be on the box — add it under
132
+ Account → SSH Keys (or `pyvolt` on the web dashboard)."""
133
+ import os
134
+ import shutil
135
+
136
+ if not shutil.which("ssh"):
137
+ raise fail("No `ssh` client found on PATH.")
138
+ api = Api()
139
+ srv = api.resolve_server(server)
140
+ if srv["status"] != "ready":
141
+ raise fail(f"{srv['name']} is {srv['status']}, not ready.")
142
+ target = f"{srv.get('ssh_user', 'pyvolt')}@{srv['ip_address']}"
143
+ args = ["ssh", "-p", str(srv.get("ssh_port", 22)), target]
144
+ if app_name:
145
+ site = api.resolve_app(app_name)
146
+ # Drop into the app's checkout, then hand over an interactive shell.
147
+ args += ["-t", f"cd sites/{site['domain']}/repo 2>/dev/null; exec \"$SHELL\" -l"]
148
+ console.print(f"[dim]Connecting to {target}…[/dim]")
149
+ os.execvp("ssh", args)
150
+
151
+
152
+ @app.command()
153
+ def apps(server: str = typer.Option("", "--server", help="Filter by server name.")):
154
+ """List your apps."""
155
+ t = _table("DOMAIN", "SERVER", "STATUS", "REPO", "BRANCH")
156
+ for a in Api().get("/v1/apps", params={"server": server} if server else None):
157
+ t.add_row(a["domain"], a["server"], _status(a["status"]), a["repo"], a["branch"])
158
+ console.print(t)
159
+
160
+
161
+ # ---- deployments --------------------------------------------------------------
162
+
163
+ TERMINAL = ("succeeded", "failed")
164
+
165
+
166
+ def _follow(api: Api, deployment_id: int) -> str:
167
+ offset, status = 0, "queued"
168
+ while status not in TERMINAL:
169
+ time.sleep(2)
170
+ chunk = api.get(f"/v1/deployments/{deployment_id}/log", params={"offset": offset})
171
+ status, offset = chunk["status"], chunk["offset"]
172
+ if chunk["chunk"]:
173
+ console.out(chunk["chunk"], end="", highlight=False)
174
+ return status
175
+
176
+
177
+ @app.command()
178
+ def deploy(
179
+ app_name: str = typer.Argument(..., metavar="DOMAIN"),
180
+ follow: bool = typer.Option(False, "--follow", "-f", help="Stream the deploy log."),
181
+ ):
182
+ """Trigger a deployment."""
183
+ api = Api()
184
+ site = api.resolve_app(app_name)
185
+ r = api.post(f"/v1/apps/{site['id']}/deployments", ok=(201, 409))
186
+ if r.status_code == 409:
187
+ raise fail(r.json()["detail"])
188
+ d = r.json()
189
+ console.print(f"[green]✓[/green] Deployment queued for [bold]{site['domain']}[/bold]")
190
+ if not follow:
191
+ console.print(f" pyvolt deploy {site['domain']} --follow (or watch {site['dashboard_url']})")
192
+ return
193
+ status = _follow(api, d["id"])
194
+ if status == "failed":
195
+ raise fail("Deployment failed.")
196
+ console.print(f"\n[green]✓[/green] Deployed {site['domain']}")
197
+
198
+
199
+ @app.command()
200
+ def deployments(app_name: str = typer.Argument(..., metavar="DOMAIN")):
201
+ """Recent deployments for an app."""
202
+ api = Api()
203
+ site = api.resolve_app(app_name)
204
+ t = _table("WHEN", "STATUS", "COMMIT", "MESSAGE", "BY", "TOOK")
205
+ for d in api.get(f"/v1/apps/{site['id']}/deployments"):
206
+ took = f"{d['duration_seconds']}s" if d["duration_seconds"] is not None else ""
207
+ t.add_row(
208
+ d["created_at"][:16].replace("T", " "), _status(d["status"]),
209
+ d["commit_sha"][:7], d["commit_message"].splitlines()[0][:60] if d["commit_message"] else "",
210
+ d["triggered_by"], took,
211
+ )
212
+ console.print(t)
213
+
214
+
215
+ # ---- env ----------------------------------------------------------------------
216
+
217
+ env_app = typer.Typer(no_args_is_help=True, help="Manage an app's environment variables.")
218
+ app.add_typer(env_app, name="env")
219
+
220
+
221
+ @env_app.callback()
222
+ def _env_main(ctx: typer.Context, app_name: str = typer.Argument(..., metavar="DOMAIN")):
223
+ ctx.obj = app_name
224
+
225
+
226
+ @env_app.command("list")
227
+ def env_list(ctx: typer.Context):
228
+ """Show the app's variables (secret values masked)."""
229
+ api = Api()
230
+ site = api.resolve_app(ctx.obj)
231
+ t = _table("KEY", "VALUE")
232
+ for row in api.get(f"/v1/apps/{site['id']}/env"):
233
+ t.add_row(row["key"], "••••••••" if row["is_secret"] else row["value"])
234
+ console.print(t)
235
+
236
+
237
+ @env_app.command("set")
238
+ def env_set(ctx: typer.Context, pairs: list[str] = typer.Argument(..., metavar="KEY=VALUE...")):
239
+ """Set one or more variables — pushes the .env and restarts the app."""
240
+ api = Api()
241
+ site = api.resolve_app(ctx.obj)
242
+ for pair in pairs:
243
+ if "=" not in pair:
244
+ raise fail(f"Expected KEY=VALUE, got [bold]{pair}[/bold]")
245
+ key, _, value = pair.partition("=")
246
+ api.post(f"/v1/apps/{site['id']}/env", json={"key": key, "value": value})
247
+ console.print(f"[green]✓[/green] {key}")
248
+
249
+
250
+ @env_app.command("rm")
251
+ def env_rm(ctx: typer.Context, key: str):
252
+ """Remove a variable — pushes the .env and restarts the app."""
253
+ api = Api()
254
+ site = api.resolve_app(ctx.obj)
255
+ r = api.delete(f"/v1/apps/{site['id']}/env/{key}")
256
+ if not r["deleted"]:
257
+ raise fail(f"[bold]{key}[/bold] is not set.")
258
+ console.print(f"[green]✓[/green] {key} removed")
259
+
260
+
261
+ # ---- processes & logs -----------------------------------------------------------
262
+
263
+ @app.command()
264
+ def ps(
265
+ app_name: str = typer.Argument(..., metavar="DOMAIN"),
266
+ action: str = typer.Argument("", metavar="[restart]"),
267
+ process: str = typer.Argument("", metavar="[NAME]"),
268
+ ):
269
+ """List background processes; `pyvolt ps APP restart NAME` restarts one."""
270
+ api = Api()
271
+ site = api.resolve_app(app_name)
272
+ procs = api.get(f"/v1/apps/{site['id']}/processes")
273
+ if action:
274
+ if action != "restart" or not process:
275
+ raise fail("Usage: pyvolt ps DOMAIN restart NAME")
276
+ match = [p for p in procs if p["name"] == process]
277
+ if not match:
278
+ known = ", ".join(p["name"] for p in procs) or "none"
279
+ raise fail(f"No process [bold]{process}[/bold]. Processes: {known}")
280
+ api.post(f"/v1/apps/{site['id']}/processes/{match[0]['id']}/restart")
281
+ console.print(f"[green]✓[/green] Restarted {process}")
282
+ return
283
+ t = _table("NAME", "KIND", "STATE", "COMMAND")
284
+ for p in procs:
285
+ state = _status(p["state"]) + (f" ({p['exit']})" if p["exit"] else "")
286
+ t.add_row(p["name"], p["kind"], state, p["command"])
287
+ console.print(t)
288
+
289
+
290
+ @app.command()
291
+ def logs(
292
+ app_name: str = typer.Argument(..., metavar="DOMAIN"),
293
+ process: str = typer.Option("", "--process", "-p", help="A named background process instead of the web app."),
294
+ lines: int = typer.Option(100, "--lines", "-n", help="How many lines (max 500)."),
295
+ ):
296
+ """Tail the app's journal."""
297
+ api = Api()
298
+ site = api.resolve_app(app_name)
299
+ params = {"lines": lines}
300
+ if process:
301
+ params["process"] = process
302
+ r = api.get(f"/v1/apps/{site['id']}/logs", params=params)
303
+ if r.get("error"):
304
+ raise fail(f"journalctl failed on the server: {r['error']}")
305
+ for line in r["lines"]:
306
+ console.out(line, highlight=False)
307
+
308
+
309
+ # ---- fail2ban -------------------------------------------------------------------
310
+
311
+ @app.command()
312
+ def bans(server: str = typer.Argument(..., metavar="SERVER")):
313
+ """List IPs banned by fail2ban on a server (flags your own IP)."""
314
+ api = Api()
315
+ srv = api.resolve_server(server)
316
+ r = api.get(f"/v1/servers/{srv['id']}/bans")
317
+ if not r["running"]:
318
+ raise fail("fail2ban is not active on this server.")
319
+ if not r["banned"]:
320
+ console.print("[green]✓[/green] No IPs are currently banned.")
321
+ else:
322
+ t = _table("BANNED IP", "")
323
+ for ip in r["banned"]:
324
+ t.add_row(ip, "[yellow]← this is you[/]" if ip == r["your_ip"] else "")
325
+ console.print(t)
326
+ console.print(f"[dim]Your public IP: {r['your_ip']}[/dim]")
327
+ if r["your_ip"] in r["banned"]:
328
+ console.print(f"Unban yourself: [bold]pyvolt unban {srv['name']} --me[/bold]")
329
+
330
+
331
+ @app.command()
332
+ def unban(
333
+ server: str = typer.Argument(..., metavar="SERVER"),
334
+ ip: str = typer.Argument("", metavar="[IP]"),
335
+ me: bool = typer.Option(False, "--me", help="Unban your own public IP (as the API sees it)."),
336
+ ):
337
+ """Lift a fail2ban ban — banned yourself? `pyvolt unban SERVER --me`."""
338
+ api = Api()
339
+ srv = api.resolve_server(server)
340
+ if me:
341
+ ip = api.get(f"/v1/servers/{srv['id']}/bans")["your_ip"]
342
+ if not ip:
343
+ raise fail("Give an IP, or use [bold]--me[/bold] to unban your own.")
344
+ r = api.post(f"/v1/servers/{srv['id']}/bans/unban", ok=(200, 400), json={"ip": ip})
345
+ if r.status_code == 400:
346
+ raise fail(r.json()["detail"])
347
+ console.print(f"[green]✓[/green] Unbanned {ip}")
348
+
349
+
350
+ @app.command("open")
351
+ def open_(app_name: str = typer.Argument(..., metavar="DOMAIN")):
352
+ """Open the app's dashboard page in your browser."""
353
+ site = Api().resolve_app(app_name)
354
+ console.print(f"Opening {site['dashboard_url']}")
355
+ webbrowser.open(site["dashboard_url"])
@@ -0,0 +1,84 @@
1
+ """Thin httpx wrapper over the Pyvolt API (interactive docs at /api/docs)."""
2
+ from __future__ import annotations
3
+
4
+ import httpx
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from . import config
9
+
10
+ err = Console(stderr=True)
11
+
12
+
13
+ def fail(message: str) -> typer.Exit:
14
+ err.print(f"[red]✗[/red] {message}")
15
+ return typer.Exit(1)
16
+
17
+
18
+ class Api:
19
+ """Authenticated client. Instantiating without a stored token exits."""
20
+
21
+ def __init__(self) -> None:
22
+ tok = config.token()
23
+ if not tok:
24
+ raise fail("Not logged in — run [bold]pyvolt login[/bold] first.")
25
+ self.base = config.api_url()
26
+ self.http = httpx.Client(
27
+ base_url=self.base + "/api",
28
+ headers={"Authorization": f"Bearer {tok}"},
29
+ timeout=30,
30
+ )
31
+
32
+ def request(self, method: str, path: str, *, ok=(200, 201), **kw) -> httpx.Response:
33
+ try:
34
+ r = self.http.request(method, path, **kw)
35
+ except httpx.HTTPError as e:
36
+ raise fail(f"Could not reach {self.base}: {e}") from e
37
+ if r.status_code == 401:
38
+ raise fail("Token rejected (revoked or expired) — run [bold]pyvolt login[/bold] again.")
39
+ if r.status_code == 404:
40
+ raise fail("Not found.")
41
+ if r.status_code not in ok:
42
+ raise fail(f"API error {r.status_code}: {r.text[:200]}")
43
+ return r
44
+
45
+ def get(self, path: str, **kw):
46
+ return self.request("GET", path, **kw).json()
47
+
48
+ def post(self, path: str, *, ok=(200, 201), **kw) -> httpx.Response:
49
+ return self.request("POST", path, ok=ok, **kw)
50
+
51
+ def delete(self, path: str, **kw):
52
+ return self.request("DELETE", path, **kw).json()
53
+
54
+ # -- resolution ---------------------------------------------------------
55
+
56
+ def resolve_server(self, name: str) -> dict:
57
+ """Match a server by exact name, else unambiguous substring."""
58
+ servers = self.get("/v1/servers")
59
+ exact = [s for s in servers if s["name"] == name]
60
+ if exact:
61
+ return exact[0]
62
+ matches = [s for s in servers if name.lower() in s["name"].lower()]
63
+ if len(matches) == 1:
64
+ return matches[0]
65
+ if not matches:
66
+ known = ", ".join(s["name"] for s in servers) or "none yet"
67
+ raise fail(f"No server matches [bold]{name}[/bold]. Your servers: {known}")
68
+ ambiguous = ", ".join(s["name"] for s in matches)
69
+ raise fail(f"[bold]{name}[/bold] is ambiguous: {ambiguous}")
70
+
71
+ def resolve_app(self, name: str) -> dict:
72
+ """Match an app by exact domain, else unambiguous substring."""
73
+ apps = self.get("/v1/apps")
74
+ exact = [a for a in apps if a["domain"] == name]
75
+ if exact:
76
+ return exact[0]
77
+ matches = [a for a in apps if name.lower() in a["domain"].lower()]
78
+ if len(matches) == 1:
79
+ return matches[0]
80
+ if not matches:
81
+ known = ", ".join(a["domain"] for a in apps) or "none yet"
82
+ raise fail(f"No app matches [bold]{name}[/bold]. Your apps: {known}")
83
+ ambiguous = ", ".join(a["domain"] for a in matches)
84
+ raise fail(f"[bold]{name}[/bold] is ambiguous: {ambiguous}")
@@ -0,0 +1,56 @@
1
+ """Credentials + endpoint resolution.
2
+
3
+ Token lives in ~/.config/pyvolt/credentials.toml (mode 600). Environment
4
+ overrides — PYVOLT_TOKEN, PYVOLT_API_URL — win over the file, so CI and
5
+ local-dev instances need no config file at all.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ DEFAULT_API_URL = "https://pyvolt.com"
14
+
15
+
16
+ def config_dir() -> Path:
17
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
18
+ return Path(base) / "pyvolt"
19
+
20
+
21
+ def credentials_path() -> Path:
22
+ return config_dir() / "credentials.toml"
23
+
24
+
25
+ def _load() -> dict:
26
+ p = credentials_path()
27
+ if not p.exists():
28
+ return {}
29
+ try:
30
+ return tomllib.loads(p.read_text())
31
+ except tomllib.TOMLDecodeError:
32
+ return {}
33
+
34
+
35
+ def api_url() -> str:
36
+ return (os.environ.get("PYVOLT_API_URL") or _load().get("api_url") or DEFAULT_API_URL).rstrip("/")
37
+
38
+
39
+ def token() -> str | None:
40
+ return os.environ.get("PYVOLT_TOKEN") or _load().get("token")
41
+
42
+
43
+ def save(tok: str, api: str | None = None) -> Path:
44
+ d = config_dir()
45
+ d.mkdir(parents=True, exist_ok=True)
46
+ lines = [f'token = "{tok}"']
47
+ if api and api.rstrip("/") != DEFAULT_API_URL:
48
+ lines.append(f'api_url = "{api.rstrip("/")}"')
49
+ p = credentials_path()
50
+ p.write_text("\n".join(lines) + "\n")
51
+ p.chmod(0o600)
52
+ return p
53
+
54
+
55
+ def clear() -> None:
56
+ credentials_path().unlink(missing_ok=True)
pyvolt_cli-0.0.1/PKG-INFO DELETED
@@ -1,65 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pyvolt-cli
3
- Version: 0.0.1
4
- Summary: The command-line companion to pyvolt: managed Python deployments on infrastructure you actually own.
5
- Project-URL: Homepage, https://pyvolt.com
6
- Project-URL: Repository, https://github.com/pyvolt-hq/pyvolt-cli
7
- Project-URL: Issues, https://github.com/pyvolt-hq/pyvolt-cli/issues
8
- Author-email: Will Abbott <will@digitaloutback.co.uk>
9
- Maintainer-email: PyVolt HQ <will@digitaloutback.co.uk>
10
- License: MIT
11
- License-File: LICENSE
12
- Keywords: deployment,digitalocean,django,fastapi,flask,hetzner,paas,python,vps
13
- Classifier: Development Status :: 1 - Planning
14
- Classifier: Environment :: Console
15
- Classifier: Intended Audience :: Developers
16
- Classifier: License :: OSI Approved :: MIT License
17
- Classifier: Operating System :: OS Independent
18
- Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.10
20
- Classifier: Programming Language :: Python :: 3.11
21
- Classifier: Programming Language :: Python :: 3.12
22
- Classifier: Programming Language :: Python :: 3.13
23
- Classifier: Topic :: Software Development :: Build Tools
24
- Classifier: Topic :: System :: Installation/Setup
25
- Classifier: Topic :: System :: Systems Administration
26
- Requires-Python: >=3.10
27
- Description-Content-Type: text/markdown
28
-
29
- # pyvolt-cli
30
-
31
- The terminal companion to [pyvolt](https://pyvolt.com): managed Python deployments on infrastructure you actually own.
32
-
33
- ## Status
34
-
35
- This is a **placeholder release (0.0.1)**. It installs the `pyvolt` command onto `$PATH` and prints a "coming soon" banner. Real subcommands (`pyvolt deploy`, `pyvolt logs`, `pyvolt scale`, and friends) land in a future release.
36
-
37
- Follow along at:
38
-
39
- - Website: <https://pyvolt.com>
40
- - Repository: <https://github.com/pyvolt-hq/pyvolt-cli>
41
- - Issues: <https://github.com/pyvolt-hq/pyvolt-cli/issues>
42
-
43
- ## Install
44
-
45
- ```bash
46
- pip install pyvolt-cli
47
- ```
48
-
49
- The distribution name on PyPI is `pyvolt-cli`; the installed command on your `$PATH` is `pyvolt`.
50
-
51
- ## Verify
52
-
53
- ```bash
54
- pyvolt
55
- ```
56
-
57
- Prints the version banner. If you see it, the entry point is wired correctly.
58
-
59
- ## Requires
60
-
61
- Python 3.10 or newer.
62
-
63
- ## License
64
-
65
- MIT. See [LICENSE](LICENSE).
@@ -1,37 +0,0 @@
1
- # pyvolt-cli
2
-
3
- The terminal companion to [pyvolt](https://pyvolt.com): managed Python deployments on infrastructure you actually own.
4
-
5
- ## Status
6
-
7
- This is a **placeholder release (0.0.1)**. It installs the `pyvolt` command onto `$PATH` and prints a "coming soon" banner. Real subcommands (`pyvolt deploy`, `pyvolt logs`, `pyvolt scale`, and friends) land in a future release.
8
-
9
- Follow along at:
10
-
11
- - Website: <https://pyvolt.com>
12
- - Repository: <https://github.com/pyvolt-hq/pyvolt-cli>
13
- - Issues: <https://github.com/pyvolt-hq/pyvolt-cli/issues>
14
-
15
- ## Install
16
-
17
- ```bash
18
- pip install pyvolt-cli
19
- ```
20
-
21
- The distribution name on PyPI is `pyvolt-cli`; the installed command on your `$PATH` is `pyvolt`.
22
-
23
- ## Verify
24
-
25
- ```bash
26
- pyvolt
27
- ```
28
-
29
- Prints the version banner. If you see it, the entry point is wired correctly.
30
-
31
- ## Requires
32
-
33
- Python 3.10 or newer.
34
-
35
- ## License
36
-
37
- MIT. See [LICENSE](LICENSE).
@@ -1,8 +0,0 @@
1
- """pyvolt-cli: the terminal companion to pyvolt.
2
-
3
- This is a placeholder release that reserves the ``pyvolt`` command on the
4
- user's ``$PATH``. Real subcommands land in a future release. See
5
- https://pyvolt.com for status.
6
- """
7
-
8
- __version__ = "0.0.1"
@@ -1,32 +0,0 @@
1
- """Entry point for the ``pyvolt`` command.
2
-
3
- The pyproject.toml maps ``[project.scripts] pyvolt`` to :func:`main` in this
4
- module. Running ``pyvolt`` after ``pip install pyvolt-cli`` lands here.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import sys
10
-
11
- from . import __version__
12
-
13
-
14
- BANNER = f"""
15
- pyvolt {__version__}
16
-
17
- Managed Python deployments on infrastructure you actually own.
18
-
19
- This CLI is a placeholder release. Real subcommands land in a future
20
- version. Track progress at https://pyvolt.com and
21
- https://github.com/pyvolt-hq/pyvolt-cli.
22
- """.strip()
23
-
24
-
25
- def main() -> int:
26
- """Print a coming-soon banner and exit cleanly."""
27
- print(BANNER)
28
- return 0
29
-
30
-
31
- if __name__ == "__main__": # pragma: no cover
32
- sys.exit(main())
File without changes