pyvolt-cli 0.0.1__tar.gz → 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyvolt_cli-0.1.0/.gitignore +4 -0
- pyvolt_cli-0.1.0/PKG-INFO +70 -0
- pyvolt_cli-0.1.0/README.md +40 -0
- {pyvolt_cli-0.0.1 → pyvolt_cli-0.1.0}/pyproject.toml +16 -6
- pyvolt_cli-0.1.0/pyvolt_cli/__init__.py +4 -0
- pyvolt_cli-0.1.0/pyvolt_cli/__main__.py +12 -0
- pyvolt_cli-0.1.0/pyvolt_cli/app.py +326 -0
- pyvolt_cli-0.1.0/pyvolt_cli/client.py +84 -0
- pyvolt_cli-0.1.0/pyvolt_cli/config.py +56 -0
- pyvolt_cli-0.0.1/PKG-INFO +0 -65
- pyvolt_cli-0.0.1/README.md +0 -37
- pyvolt_cli-0.0.1/pyvolt_cli/__init__.py +0 -8
- pyvolt_cli-0.0.1/pyvolt_cli/__main__.py +0 -32
- {pyvolt_cli-0.0.1 → pyvolt_cli-0.1.0}/LICENSE +0 -0
- {pyvolt_cli-0.0.1 → pyvolt_cli-0.1.0}/pyvolt_cli/py.typed +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyvolt-cli
|
|
3
|
+
Version: 0.1.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
|
|
8
|
-
description = "The command-line companion to
|
|
7
|
+
version = "0.1.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.
|
|
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 = "
|
|
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 ::
|
|
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,326 @@
|
|
|
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 apps(server: str = typer.Option("", "--server", help="Filter by server name.")):
|
|
125
|
+
"""List your apps."""
|
|
126
|
+
t = _table("DOMAIN", "SERVER", "STATUS", "REPO", "BRANCH")
|
|
127
|
+
for a in Api().get("/v1/apps", params={"server": server} if server else None):
|
|
128
|
+
t.add_row(a["domain"], a["server"], _status(a["status"]), a["repo"], a["branch"])
|
|
129
|
+
console.print(t)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---- deployments --------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
TERMINAL = ("succeeded", "failed")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _follow(api: Api, deployment_id: int) -> str:
|
|
138
|
+
offset, status = 0, "queued"
|
|
139
|
+
while status not in TERMINAL:
|
|
140
|
+
time.sleep(2)
|
|
141
|
+
chunk = api.get(f"/v1/deployments/{deployment_id}/log", params={"offset": offset})
|
|
142
|
+
status, offset = chunk["status"], chunk["offset"]
|
|
143
|
+
if chunk["chunk"]:
|
|
144
|
+
console.out(chunk["chunk"], end="", highlight=False)
|
|
145
|
+
return status
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command()
|
|
149
|
+
def deploy(
|
|
150
|
+
app_name: str = typer.Argument(..., metavar="DOMAIN"),
|
|
151
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Stream the deploy log."),
|
|
152
|
+
):
|
|
153
|
+
"""Trigger a deployment."""
|
|
154
|
+
api = Api()
|
|
155
|
+
site = api.resolve_app(app_name)
|
|
156
|
+
r = api.post(f"/v1/apps/{site['id']}/deployments", ok=(201, 409))
|
|
157
|
+
if r.status_code == 409:
|
|
158
|
+
raise fail(r.json()["detail"])
|
|
159
|
+
d = r.json()
|
|
160
|
+
console.print(f"[green]✓[/green] Deployment queued for [bold]{site['domain']}[/bold]")
|
|
161
|
+
if not follow:
|
|
162
|
+
console.print(f" pyvolt deploy {site['domain']} --follow (or watch {site['dashboard_url']})")
|
|
163
|
+
return
|
|
164
|
+
status = _follow(api, d["id"])
|
|
165
|
+
if status == "failed":
|
|
166
|
+
raise fail("Deployment failed.")
|
|
167
|
+
console.print(f"\n[green]✓[/green] Deployed {site['domain']}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def deployments(app_name: str = typer.Argument(..., metavar="DOMAIN")):
|
|
172
|
+
"""Recent deployments for an app."""
|
|
173
|
+
api = Api()
|
|
174
|
+
site = api.resolve_app(app_name)
|
|
175
|
+
t = _table("WHEN", "STATUS", "COMMIT", "MESSAGE", "BY", "TOOK")
|
|
176
|
+
for d in api.get(f"/v1/apps/{site['id']}/deployments"):
|
|
177
|
+
took = f"{d['duration_seconds']}s" if d["duration_seconds"] is not None else ""
|
|
178
|
+
t.add_row(
|
|
179
|
+
d["created_at"][:16].replace("T", " "), _status(d["status"]),
|
|
180
|
+
d["commit_sha"][:7], d["commit_message"].splitlines()[0][:60] if d["commit_message"] else "",
|
|
181
|
+
d["triggered_by"], took,
|
|
182
|
+
)
|
|
183
|
+
console.print(t)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---- env ----------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
env_app = typer.Typer(no_args_is_help=True, help="Manage an app's environment variables.")
|
|
189
|
+
app.add_typer(env_app, name="env")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@env_app.callback()
|
|
193
|
+
def _env_main(ctx: typer.Context, app_name: str = typer.Argument(..., metavar="DOMAIN")):
|
|
194
|
+
ctx.obj = app_name
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@env_app.command("list")
|
|
198
|
+
def env_list(ctx: typer.Context):
|
|
199
|
+
"""Show the app's variables (secret values masked)."""
|
|
200
|
+
api = Api()
|
|
201
|
+
site = api.resolve_app(ctx.obj)
|
|
202
|
+
t = _table("KEY", "VALUE")
|
|
203
|
+
for row in api.get(f"/v1/apps/{site['id']}/env"):
|
|
204
|
+
t.add_row(row["key"], "••••••••" if row["is_secret"] else row["value"])
|
|
205
|
+
console.print(t)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@env_app.command("set")
|
|
209
|
+
def env_set(ctx: typer.Context, pairs: list[str] = typer.Argument(..., metavar="KEY=VALUE...")):
|
|
210
|
+
"""Set one or more variables — pushes the .env and restarts the app."""
|
|
211
|
+
api = Api()
|
|
212
|
+
site = api.resolve_app(ctx.obj)
|
|
213
|
+
for pair in pairs:
|
|
214
|
+
if "=" not in pair:
|
|
215
|
+
raise fail(f"Expected KEY=VALUE, got [bold]{pair}[/bold]")
|
|
216
|
+
key, _, value = pair.partition("=")
|
|
217
|
+
api.post(f"/v1/apps/{site['id']}/env", json={"key": key, "value": value})
|
|
218
|
+
console.print(f"[green]✓[/green] {key}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@env_app.command("rm")
|
|
222
|
+
def env_rm(ctx: typer.Context, key: str):
|
|
223
|
+
"""Remove a variable — pushes the .env and restarts the app."""
|
|
224
|
+
api = Api()
|
|
225
|
+
site = api.resolve_app(ctx.obj)
|
|
226
|
+
r = api.delete(f"/v1/apps/{site['id']}/env/{key}")
|
|
227
|
+
if not r["deleted"]:
|
|
228
|
+
raise fail(f"[bold]{key}[/bold] is not set.")
|
|
229
|
+
console.print(f"[green]✓[/green] {key} removed")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---- processes & logs -----------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
def ps(
|
|
236
|
+
app_name: str = typer.Argument(..., metavar="DOMAIN"),
|
|
237
|
+
action: str = typer.Argument("", metavar="[restart]"),
|
|
238
|
+
process: str = typer.Argument("", metavar="[NAME]"),
|
|
239
|
+
):
|
|
240
|
+
"""List background processes; `pyvolt ps APP restart NAME` restarts one."""
|
|
241
|
+
api = Api()
|
|
242
|
+
site = api.resolve_app(app_name)
|
|
243
|
+
procs = api.get(f"/v1/apps/{site['id']}/processes")
|
|
244
|
+
if action:
|
|
245
|
+
if action != "restart" or not process:
|
|
246
|
+
raise fail("Usage: pyvolt ps DOMAIN restart NAME")
|
|
247
|
+
match = [p for p in procs if p["name"] == process]
|
|
248
|
+
if not match:
|
|
249
|
+
known = ", ".join(p["name"] for p in procs) or "none"
|
|
250
|
+
raise fail(f"No process [bold]{process}[/bold]. Processes: {known}")
|
|
251
|
+
api.post(f"/v1/apps/{site['id']}/processes/{match[0]['id']}/restart")
|
|
252
|
+
console.print(f"[green]✓[/green] Restarted {process}")
|
|
253
|
+
return
|
|
254
|
+
t = _table("NAME", "KIND", "STATE", "COMMAND")
|
|
255
|
+
for p in procs:
|
|
256
|
+
state = _status(p["state"]) + (f" ({p['exit']})" if p["exit"] else "")
|
|
257
|
+
t.add_row(p["name"], p["kind"], state, p["command"])
|
|
258
|
+
console.print(t)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.command()
|
|
262
|
+
def logs(
|
|
263
|
+
app_name: str = typer.Argument(..., metavar="DOMAIN"),
|
|
264
|
+
process: str = typer.Option("", "--process", "-p", help="A named background process instead of the web app."),
|
|
265
|
+
lines: int = typer.Option(100, "--lines", "-n", help="How many lines (max 500)."),
|
|
266
|
+
):
|
|
267
|
+
"""Tail the app's journal."""
|
|
268
|
+
api = Api()
|
|
269
|
+
site = api.resolve_app(app_name)
|
|
270
|
+
params = {"lines": lines}
|
|
271
|
+
if process:
|
|
272
|
+
params["process"] = process
|
|
273
|
+
r = api.get(f"/v1/apps/{site['id']}/logs", params=params)
|
|
274
|
+
if r.get("error"):
|
|
275
|
+
raise fail(f"journalctl failed on the server: {r['error']}")
|
|
276
|
+
for line in r["lines"]:
|
|
277
|
+
console.out(line, highlight=False)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---- fail2ban -------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
@app.command()
|
|
283
|
+
def bans(server: str = typer.Argument(..., metavar="SERVER")):
|
|
284
|
+
"""List IPs banned by fail2ban on a server (flags your own IP)."""
|
|
285
|
+
api = Api()
|
|
286
|
+
srv = api.resolve_server(server)
|
|
287
|
+
r = api.get(f"/v1/servers/{srv['id']}/bans")
|
|
288
|
+
if not r["running"]:
|
|
289
|
+
raise fail("fail2ban is not active on this server.")
|
|
290
|
+
if not r["banned"]:
|
|
291
|
+
console.print("[green]✓[/green] No IPs are currently banned.")
|
|
292
|
+
else:
|
|
293
|
+
t = _table("BANNED IP", "")
|
|
294
|
+
for ip in r["banned"]:
|
|
295
|
+
t.add_row(ip, "[yellow]← this is you[/]" if ip == r["your_ip"] else "")
|
|
296
|
+
console.print(t)
|
|
297
|
+
console.print(f"[dim]Your public IP: {r['your_ip']}[/dim]")
|
|
298
|
+
if r["your_ip"] in r["banned"]:
|
|
299
|
+
console.print(f"Unban yourself: [bold]pyvolt unban {srv['name']} --me[/bold]")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@app.command()
|
|
303
|
+
def unban(
|
|
304
|
+
server: str = typer.Argument(..., metavar="SERVER"),
|
|
305
|
+
ip: str = typer.Argument("", metavar="[IP]"),
|
|
306
|
+
me: bool = typer.Option(False, "--me", help="Unban your own public IP (as the API sees it)."),
|
|
307
|
+
):
|
|
308
|
+
"""Lift a fail2ban ban — banned yourself? `pyvolt unban SERVER --me`."""
|
|
309
|
+
api = Api()
|
|
310
|
+
srv = api.resolve_server(server)
|
|
311
|
+
if me:
|
|
312
|
+
ip = api.get(f"/v1/servers/{srv['id']}/bans")["your_ip"]
|
|
313
|
+
if not ip:
|
|
314
|
+
raise fail("Give an IP, or use [bold]--me[/bold] to unban your own.")
|
|
315
|
+
r = api.post(f"/v1/servers/{srv['id']}/bans/unban", ok=(200, 400), json={"ip": ip})
|
|
316
|
+
if r.status_code == 400:
|
|
317
|
+
raise fail(r.json()["detail"])
|
|
318
|
+
console.print(f"[green]✓[/green] Unbanned {ip}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command("open")
|
|
322
|
+
def open_(app_name: str = typer.Argument(..., metavar="DOMAIN")):
|
|
323
|
+
"""Open the app's dashboard page in your browser."""
|
|
324
|
+
site = Api().resolve_app(app_name)
|
|
325
|
+
console.print(f"Opening {site['dashboard_url']}")
|
|
326
|
+
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).
|
pyvolt_cli-0.0.1/README.md
DELETED
|
@@ -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,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
|
|
File without changes
|