pyvolt-cli 0.2.0__tar.gz → 0.3.1__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.1
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
@@ -45,11 +45,12 @@ pyvolt deploy myapp --follow
45
45
  | `pyvolt servers` / `pyvolt apps` | Inventory with live status |
46
46
  | `pyvolt deploy DOMAIN [--follow]` | Trigger a deployment, optionally streaming the log |
47
47
  | `pyvolt deployments DOMAIN` | Recent deployment history |
48
- | `pyvolt env DOMAIN list\|set K=V…\|rm K` | Environment variables — pushed and app restarted |
48
+ | `pyvolt env DOMAIN list\|get K\|set K=V…\|rm K` | Environment variables — pushed and app restarted |
49
49
  | `pyvolt ps DOMAIN [restart NAME]` | Background processes with live systemd state |
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
@@ -15,11 +15,12 @@ pyvolt deploy myapp --follow
15
15
  | `pyvolt servers` / `pyvolt apps` | Inventory with live status |
16
16
  | `pyvolt deploy DOMAIN [--follow]` | Trigger a deployment, optionally streaming the log |
17
17
  | `pyvolt deployments DOMAIN` | Recent deployment history |
18
- | `pyvolt env DOMAIN list\|set K=V…\|rm K` | Environment variables — pushed and app restarted |
18
+ | `pyvolt env DOMAIN list\|get K\|set K=V…\|rm K` | Environment variables — pushed and app restarted |
19
19
  | `pyvolt ps DOMAIN [restart NAME]` | Background processes with live systemd state |
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.1"
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,12 +273,26 @@ 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
 
236
284
 
285
+ @env_app.command("get")
286
+ def env_get(ctx: typer.Context, key: str):
287
+ """Show one variable's value — unmasked, since it's your own secret."""
288
+ api = Api()
289
+ site = api.resolve_app(ctx.obj)
290
+ row = api.get(f"/v1/apps/{site['id']}/env/{key}")
291
+ if _emit(row):
292
+ return
293
+ console.print(row["value"])
294
+
295
+
237
296
  @env_app.command("set")
238
297
  def env_set(ctx: typer.Context, pairs: list[str] = typer.Argument(..., metavar="KEY=VALUE...")):
239
298
  """Set one or more variables — pushes the .env and restarts the app."""
@@ -280,6 +339,8 @@ def ps(
280
339
  api.post(f"/v1/apps/{site['id']}/processes/{match[0]['id']}/restart")
281
340
  console.print(f"[green]✓[/green] Restarted {process}")
282
341
  return
342
+ if _emit(procs):
343
+ return
283
344
  t = _table("NAME", "KIND", "STATE", "COMMAND")
284
345
  for p in procs:
285
346
  state = _status(p["state"]) + (f" ({p['exit']})" if p["exit"] else "")
@@ -302,6 +363,8 @@ def logs(
302
363
  r = api.get(f"/v1/apps/{site['id']}/logs", params=params)
303
364
  if r.get("error"):
304
365
  raise fail(f"journalctl failed on the server: {r['error']}")
366
+ if _emit(r):
367
+ return
305
368
  for line in r["lines"]:
306
369
  console.out(line, highlight=False)
307
370
 
@@ -314,6 +377,8 @@ def bans(server: str = typer.Argument(..., metavar="SERVER")):
314
377
  api = Api()
315
378
  srv = api.resolve_server(server)
316
379
  r = api.get(f"/v1/servers/{srv['id']}/bans")
380
+ if _emit(r):
381
+ return
317
382
  if not r["running"]:
318
383
  raise fail("fail2ban is not active on this server.")
319
384
  if not r["banned"]:
@@ -348,8 +413,18 @@ def unban(
348
413
 
349
414
 
350
415
  @app.command("open")
351
- def open_(app_name: str = typer.Argument(..., metavar="DOMAIN")):
352
- """Open the app's dashboard page in your browser."""
416
+ def open_(
417
+ app_name: str = typer.Argument(..., metavar="DOMAIN"),
418
+ print_url: bool = typer.Option(False, "--print", "-p", help="Print the URLs instead of opening a browser."),
419
+ ):
420
+ """Open the app's dashboard in your browser (or --print the URLs)."""
353
421
  site = Api().resolve_app(app_name)
422
+ if _emit({"site_url": site.get("site_url"), "dashboard_url": site.get("dashboard_url")}):
423
+ return
424
+ if print_url:
425
+ if site.get("site_url"):
426
+ console.print(site["site_url"])
427
+ console.print(site["dashboard_url"])
428
+ return
354
429
  console.print(f"Opening {site['dashboard_url']}")
355
430
  webbrowser.open(site["dashboard_url"])
File without changes
File without changes