pyvolt-cli 0.2.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.2.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.2.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[/]",
@@ -152,12 +174,32 @@ def ssh(
152
174
  @app.command()
153
175
  def apps(server: str = typer.Option("", "--server", help="Filter by server name.")):
154
176
  """List your apps."""
177
+ data = Api().get("/v1/apps", params={"server": server} if server else None)
178
+ if _emit(data):
179
+ return
155
180
  t = _table("DOMAIN", "SERVER", "STATUS", "REPO", "BRANCH")
156
- for a in Api().get("/v1/apps", params={"server": server} if server else None):
181
+ for a in data:
157
182
  t.add_row(a["domain"], a["server"], _status(a["status"]), a["repo"], a["branch"])
158
183
  console.print(t)
159
184
 
160
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
+
161
203
  # ---- deployments --------------------------------------------------------------
162
204
 
163
205
  TERMINAL = ("succeeded", "failed")
@@ -201,8 +243,11 @@ def deployments(app_name: str = typer.Argument(..., metavar="DOMAIN")):
201
243
  """Recent deployments for an app."""
202
244
  api = Api()
203
245
  site = api.resolve_app(app_name)
246
+ data = api.get(f"/v1/apps/{site['id']}/deployments")
247
+ if _emit(data):
248
+ return
204
249
  t = _table("WHEN", "STATUS", "COMMIT", "MESSAGE", "BY", "TOOK")
205
- for d in api.get(f"/v1/apps/{site['id']}/deployments"):
250
+ for d in data:
206
251
  took = f"{d['duration_seconds']}s" if d["duration_seconds"] is not None else ""
207
252
  t.add_row(
208
253
  d["created_at"][:16].replace("T", " "), _status(d["status"]),
@@ -228,8 +273,11 @@ def env_list(ctx: typer.Context):
228
273
  """Show the app's variables (secret values masked)."""
229
274
  api = Api()
230
275
  site = api.resolve_app(ctx.obj)
276
+ data = api.get(f"/v1/apps/{site['id']}/env")
277
+ if _emit(data):
278
+ return
231
279
  t = _table("KEY", "VALUE")
232
- for row in api.get(f"/v1/apps/{site['id']}/env"):
280
+ for row in data:
233
281
  t.add_row(row["key"], "••••••••" if row["is_secret"] else row["value"])
234
282
  console.print(t)
235
283
 
@@ -280,6 +328,8 @@ def ps(
280
328
  api.post(f"/v1/apps/{site['id']}/processes/{match[0]['id']}/restart")
281
329
  console.print(f"[green]✓[/green] Restarted {process}")
282
330
  return
331
+ if _emit(procs):
332
+ return
283
333
  t = _table("NAME", "KIND", "STATE", "COMMAND")
284
334
  for p in procs:
285
335
  state = _status(p["state"]) + (f" ({p['exit']})" if p["exit"] else "")
@@ -302,6 +352,8 @@ def logs(
302
352
  r = api.get(f"/v1/apps/{site['id']}/logs", params=params)
303
353
  if r.get("error"):
304
354
  raise fail(f"journalctl failed on the server: {r['error']}")
355
+ if _emit(r):
356
+ return
305
357
  for line in r["lines"]:
306
358
  console.out(line, highlight=False)
307
359
 
@@ -314,6 +366,8 @@ def bans(server: str = typer.Argument(..., metavar="SERVER")):
314
366
  api = Api()
315
367
  srv = api.resolve_server(server)
316
368
  r = api.get(f"/v1/servers/{srv['id']}/bans")
369
+ if _emit(r):
370
+ return
317
371
  if not r["running"]:
318
372
  raise fail("fail2ban is not active on this server.")
319
373
  if not r["banned"]:
@@ -348,8 +402,18 @@ def unban(
348
402
 
349
403
 
350
404
  @app.command("open")
351
- def open_(app_name: str = typer.Argument(..., metavar="DOMAIN")):
352
- """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)."""
353
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
354
418
  console.print(f"Opening {site['dashboard_url']}")
355
419
  webbrowser.open(site["dashboard_url"])
File without changes
File without changes