pyvolt-cli 0.1.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyvolt-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: The command-line companion to Pyvolt: managed Python deployments on infrastructure you actually own.
5
5
  Project-URL: Homepage, https://pyvolt.com
6
6
  Project-URL: Repository, https://github.com/pyvolt-hq/pyvolt-cli
@@ -50,6 +50,7 @@ pyvolt deploy myapp --follow
50
50
  | `pyvolt logs DOMAIN [-p NAME] [-n N]` | Tail the app's journal |
51
51
  | `pyvolt bans SERVER` | IPs banned by fail2ban, your own flagged |
52
52
  | `pyvolt unban SERVER [IP] [--me]` | Lift a ban — `--me` unbans your own IP, even while it's banned |
53
+ | `pyvolt ssh SERVER [--app DOMAIN]` | Open a shell on the server (as the `pyvolt` user); `--app` cds into the app dir |
53
54
  | `pyvolt open DOMAIN` | Jump to the dashboard |
54
55
 
55
56
  `DOMAIN` is the app's domain, or any unambiguous fragment of it — domains
@@ -20,6 +20,7 @@ pyvolt deploy myapp --follow
20
20
  | `pyvolt logs DOMAIN [-p NAME] [-n N]` | Tail the app's journal |
21
21
  | `pyvolt bans SERVER` | IPs banned by fail2ban, your own flagged |
22
22
  | `pyvolt unban SERVER [IP] [--me]` | Lift a ban — `--me` unbans your own IP, even while it's banned |
23
+ | `pyvolt ssh SERVER [--app DOMAIN]` | Open a shell on the server (as the `pyvolt` user); `--app` cds into the app dir |
23
24
  | `pyvolt open DOMAIN` | Jump to the dashboard |
24
25
 
25
26
  `DOMAIN` is the app's domain, or any unambiguous fragment of it — domains
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyvolt-cli"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
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" }
@@ -1,4 +1,4 @@
1
1
  """pyvolt-cli: the terminal companion to Pyvolt — managed Python deployments
2
2
  on infrastructure you actually own. Docs: https://pyvolt.com/docs/cli/"""
3
3
 
4
- __version__ = "0.1.0"
4
+ __version__ = "0.3.0"
@@ -1,6 +1,7 @@
1
1
  """The Pyvolt CLI — deploy, logs, env and processes from your terminal."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json as _json
4
5
  import platform
5
6
  import time
6
7
  import webbrowser
@@ -20,6 +21,20 @@ app = typer.Typer(
20
21
  )
21
22
  console = Console()
22
23
 
24
+ # Global --json flag (set in the callback). When on, commands emit raw JSON to
25
+ # stdout instead of Rich tables, so scripts and AI coding tools get a stable,
26
+ # parseable contract instead of scraping formatted output.
27
+ _STATE = {"json": False}
28
+
29
+
30
+ def _emit(data) -> bool:
31
+ """If --json is active, print `data` as JSON and return True (the caller
32
+ should then return early). Otherwise return False and let it render normally."""
33
+ if _STATE["json"]:
34
+ print(_json.dumps(data, indent=2, default=str))
35
+ return True
36
+ return False
37
+
23
38
  STATUS_STYLE = {
24
39
  "ready": "green", "live": "green", "succeeded": "green", "running": "cyan",
25
40
  "active": "green", "queued": "yellow", "deploying": "cyan",
@@ -42,7 +57,9 @@ def _table(*columns: str) -> Table:
42
57
  def _main(
43
58
  ctx: typer.Context,
44
59
  version: bool = typer.Option(False, "--version", "-V", help="Print version and exit."),
60
+ json_output: bool = typer.Option(False, "--json", help="Emit raw JSON instead of tables (for scripts and AI tools)."),
45
61
  ):
62
+ _STATE["json"] = json_output
46
63
  if version:
47
64
  console.print(f"pyvolt-cli {__version__}")
48
65
  raise typer.Exit()
@@ -103,6 +120,8 @@ def logout():
103
120
  def whoami():
104
121
  """Show the authenticated account."""
105
122
  me = Api().get("/v1/me")
123
+ if _emit(me):
124
+ return
106
125
  console.print(f"{me['username']} <{me['email']}> @ {config.api_url()}")
107
126
 
108
127
 
@@ -111,8 +130,11 @@ def whoami():
111
130
  @app.command()
112
131
  def servers():
113
132
  """List your servers."""
133
+ data = Api().get("/v1/servers")
134
+ if _emit(data):
135
+ return
114
136
  t = _table("NAME", "IP", "PROVIDER", "STATUS", "CONNECTED")
115
- for s in Api().get("/v1/servers"):
137
+ for s in data:
116
138
  t.add_row(
117
139
  s["name"], s["ip_address"], s["provider"], _status(s["status"]),
118
140
  "[green]yes[/]" if s["connected"] else "[red]no[/]",
@@ -120,15 +142,64 @@ def servers():
120
142
  console.print(t)
121
143
 
122
144
 
145
+ @app.command()
146
+ def ssh(
147
+ server: str = typer.Argument(..., metavar="SERVER"),
148
+ app_name: str = typer.Option("", "--app", "-a", help="cd into this app's directory on connect."),
149
+ ):
150
+ """Open an SSH shell on a server (as the pyvolt app-user).
151
+
152
+ A thin convenience wrapper: it resolves the host/port from the API and
153
+ execs your local `ssh`, so your own key must be on the box — add it under
154
+ Account → SSH Keys (or `pyvolt` on the web dashboard)."""
155
+ import os
156
+ import shutil
157
+
158
+ if not shutil.which("ssh"):
159
+ raise fail("No `ssh` client found on PATH.")
160
+ api = Api()
161
+ srv = api.resolve_server(server)
162
+ if srv["status"] != "ready":
163
+ raise fail(f"{srv['name']} is {srv['status']}, not ready.")
164
+ target = f"{srv.get('ssh_user', 'pyvolt')}@{srv['ip_address']}"
165
+ args = ["ssh", "-p", str(srv.get("ssh_port", 22)), target]
166
+ if app_name:
167
+ site = api.resolve_app(app_name)
168
+ # Drop into the app's checkout, then hand over an interactive shell.
169
+ args += ["-t", f"cd sites/{site['domain']}/repo 2>/dev/null; exec \"$SHELL\" -l"]
170
+ console.print(f"[dim]Connecting to {target}…[/dim]")
171
+ os.execvp("ssh", args)
172
+
173
+
123
174
  @app.command()
124
175
  def apps(server: str = typer.Option("", "--server", help="Filter by server name.")):
125
176
  """List your apps."""
177
+ data = Api().get("/v1/apps", params={"server": server} if server else None)
178
+ if _emit(data):
179
+ return
126
180
  t = _table("DOMAIN", "SERVER", "STATUS", "REPO", "BRANCH")
127
- for a in Api().get("/v1/apps", params={"server": server} if server else None):
181
+ for a in data:
128
182
  t.add_row(a["domain"], a["server"], _status(a["status"]), a["repo"], a["branch"])
129
183
  console.print(t)
130
184
 
131
185
 
186
+ @app.command()
187
+ def status(app_name: str = typer.Argument(..., metavar="DOMAIN")):
188
+ """Show one app's details: status, URLs, repo and branch."""
189
+ site = Api().resolve_app(app_name)
190
+ if _emit(site):
191
+ return
192
+ t = _table("FIELD", "VALUE")
193
+ t.add_row("domain", site["domain"])
194
+ t.add_row("status", _status(site["status"]))
195
+ t.add_row("server", site.get("server", ""))
196
+ t.add_row("repo", f"{site.get('repo', '')} @ {site.get('branch', '')}")
197
+ if site.get("site_url"):
198
+ t.add_row("url", site["site_url"])
199
+ t.add_row("dashboard", site.get("dashboard_url", ""))
200
+ console.print(t)
201
+
202
+
132
203
  # ---- deployments --------------------------------------------------------------
133
204
 
134
205
  TERMINAL = ("succeeded", "failed")
@@ -172,8 +243,11 @@ def deployments(app_name: str = typer.Argument(..., metavar="DOMAIN")):
172
243
  """Recent deployments for an app."""
173
244
  api = Api()
174
245
  site = api.resolve_app(app_name)
246
+ data = api.get(f"/v1/apps/{site['id']}/deployments")
247
+ if _emit(data):
248
+ return
175
249
  t = _table("WHEN", "STATUS", "COMMIT", "MESSAGE", "BY", "TOOK")
176
- for d in api.get(f"/v1/apps/{site['id']}/deployments"):
250
+ for d in data:
177
251
  took = f"{d['duration_seconds']}s" if d["duration_seconds"] is not None else ""
178
252
  t.add_row(
179
253
  d["created_at"][:16].replace("T", " "), _status(d["status"]),
@@ -199,8 +273,11 @@ def env_list(ctx: typer.Context):
199
273
  """Show the app's variables (secret values masked)."""
200
274
  api = Api()
201
275
  site = api.resolve_app(ctx.obj)
276
+ data = api.get(f"/v1/apps/{site['id']}/env")
277
+ if _emit(data):
278
+ return
202
279
  t = _table("KEY", "VALUE")
203
- for row in api.get(f"/v1/apps/{site['id']}/env"):
280
+ for row in data:
204
281
  t.add_row(row["key"], "••••••••" if row["is_secret"] else row["value"])
205
282
  console.print(t)
206
283
 
@@ -251,6 +328,8 @@ def ps(
251
328
  api.post(f"/v1/apps/{site['id']}/processes/{match[0]['id']}/restart")
252
329
  console.print(f"[green]✓[/green] Restarted {process}")
253
330
  return
331
+ if _emit(procs):
332
+ return
254
333
  t = _table("NAME", "KIND", "STATE", "COMMAND")
255
334
  for p in procs:
256
335
  state = _status(p["state"]) + (f" ({p['exit']})" if p["exit"] else "")
@@ -273,6 +352,8 @@ def logs(
273
352
  r = api.get(f"/v1/apps/{site['id']}/logs", params=params)
274
353
  if r.get("error"):
275
354
  raise fail(f"journalctl failed on the server: {r['error']}")
355
+ if _emit(r):
356
+ return
276
357
  for line in r["lines"]:
277
358
  console.out(line, highlight=False)
278
359
 
@@ -285,6 +366,8 @@ def bans(server: str = typer.Argument(..., metavar="SERVER")):
285
366
  api = Api()
286
367
  srv = api.resolve_server(server)
287
368
  r = api.get(f"/v1/servers/{srv['id']}/bans")
369
+ if _emit(r):
370
+ return
288
371
  if not r["running"]:
289
372
  raise fail("fail2ban is not active on this server.")
290
373
  if not r["banned"]:
@@ -319,8 +402,18 @@ def unban(
319
402
 
320
403
 
321
404
  @app.command("open")
322
- def open_(app_name: str = typer.Argument(..., metavar="DOMAIN")):
323
- """Open the app's dashboard page in your browser."""
405
+ def open_(
406
+ app_name: str = typer.Argument(..., metavar="DOMAIN"),
407
+ print_url: bool = typer.Option(False, "--print", "-p", help="Print the URLs instead of opening a browser."),
408
+ ):
409
+ """Open the app's dashboard in your browser (or --print the URLs)."""
324
410
  site = Api().resolve_app(app_name)
411
+ if _emit({"site_url": site.get("site_url"), "dashboard_url": site.get("dashboard_url")}):
412
+ return
413
+ if print_url:
414
+ if site.get("site_url"):
415
+ console.print(site["site_url"])
416
+ console.print(site["dashboard_url"])
417
+ return
325
418
  console.print(f"Opening {site['dashboard_url']}")
326
419
  webbrowser.open(site["dashboard_url"])
File without changes
File without changes