pyvolt-cli 0.3.1__tar.gz → 0.4.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.3.1
3
+ Version: 0.4.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
@@ -48,6 +48,7 @@ pyvolt deploy myapp --follow
48
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
+ | `pyvolt metrics SERVER` | Latest CPU / memory / disk / network snapshot |
51
52
  | `pyvolt bans SERVER` | IPs banned by fail2ban, your own flagged |
52
53
  | `pyvolt unban SERVER [IP] [--me]` | Lift a ban — `--me` unbans your own IP, even while it's banned |
53
54
  | `pyvolt ssh SERVER [--app DOMAIN]` | Open a shell on the server (as the `pyvolt` user); `--app` cds into the app dir |
@@ -18,6 +18,7 @@ pyvolt deploy myapp --follow
18
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
+ | `pyvolt metrics SERVER` | Latest CPU / memory / disk / network snapshot |
21
22
  | `pyvolt bans SERVER` | IPs banned by fail2ban, your own flagged |
22
23
  | `pyvolt unban SERVER [IP] [--me]` | Lift a ban — `--me` unbans your own IP, even while it's banned |
23
24
  | `pyvolt ssh SERVER [--app DOMAIN]` | Open a shell on the server (as the `pyvolt` user); `--app` cds into the app dir |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyvolt-cli"
7
- version = "0.3.1"
7
+ version = "0.4.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" }
@@ -0,0 +1,9 @@
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
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ try:
7
+ __version__ = version("pyvolt-cli")
8
+ except PackageNotFoundError: # running from a source tree without an install
9
+ __version__ = "0.0.0.dev0"
@@ -36,7 +36,7 @@ def _emit(data) -> bool:
36
36
  return False
37
37
 
38
38
  STATUS_STYLE = {
39
- "ready": "green", "live": "green", "succeeded": "green", "running": "cyan",
39
+ "ready": "green", "live": "green", "success": "green", "running": "cyan",
40
40
  "active": "green", "queued": "yellow", "deploying": "cyan",
41
41
  "failed": "red", "error": "red", "inactive": "red",
42
42
  }
@@ -171,9 +171,18 @@ def ssh(
171
171
  os.execvp("ssh", args)
172
172
 
173
173
 
174
- @app.command()
175
- def apps(server: str = typer.Option("", "--server", help="Filter by server name.")):
174
+ apps_app = typer.Typer(help="List your apps; `pyvolt apps create` makes a new one.")
175
+ app.add_typer(apps_app, name="apps")
176
+
177
+
178
+ @apps_app.callback(invoke_without_command=True)
179
+ def apps(
180
+ ctx: typer.Context,
181
+ server: str = typer.Option("", "--server", help="Filter by server name."),
182
+ ):
176
183
  """List your apps."""
184
+ if ctx.invoked_subcommand is not None:
185
+ return
177
186
  data = Api().get("/v1/apps", params={"server": server} if server else None)
178
187
  if _emit(data):
179
188
  return
@@ -183,6 +192,101 @@ def apps(server: str = typer.Option("", "--server", help="Filter by server name.
183
192
  console.print(t)
184
193
 
185
194
 
195
+ @apps_app.command("create")
196
+ def apps_create(
197
+ server: str = typer.Option(..., "--server", "-s", metavar="SERVER", help="Server to create the app on."),
198
+ domain: str = typer.Option(..., "--domain", "-d", help="Subdomain prefix (no dots — becomes <prefix>.preview.pyvolt.com) or a full custom domain."),
199
+ repo: str = typer.Option("", "--repo", "-r", metavar="OWNER/REPO", help="GitHub repository, resolved through your connection."),
200
+ repo_url: str = typer.Option("", "--repo-url", help="Raw git URL for repos outside a GitHub connection."),
201
+ branch: str = typer.Option("main", "--branch", "-b", help="Branch deploys ship from."),
202
+ path: str = typer.Option("", "--path", help="Project directory inside the repo (monorepos)."),
203
+ env: list[str] = typer.Option([], "--env", "-e", metavar="KEY=VALUE", help="Create-time env var (repeatable)."),
204
+ serve_command: str = typer.Option("", "--serve-command", help="Override the detected start command."),
205
+ release_command: str = typer.Option("", "--release-command", help="Override the detected release steps."),
206
+ build_command: str = typer.Option("", "--build-command", help="Override the detected build command."),
207
+ python_version: str = typer.Option("", "--python", help="Override the detected Python version."),
208
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show the resolved plan without creating anything."),
209
+ ):
210
+ """Create an app on one of your servers.
211
+
212
+ Repo autodiscovery prefills the framework, serve and release commands
213
+ (same as the dashboard form); every flag above overrides it. Use
214
+ --dry-run to inspect the plan first, then create and follow up with
215
+ `pyvolt deploy DOMAIN --follow`."""
216
+ api = Api()
217
+ srv = api.resolve_server(server)
218
+ env_map: dict[str, str] = {}
219
+ for pair in env:
220
+ if "=" not in pair:
221
+ raise fail(f"Expected KEY=VALUE, got [bold]{pair}[/bold]")
222
+ key, _, value = pair.partition("=")
223
+ env_map[key] = value
224
+ payload = {
225
+ "server": srv["name"],
226
+ "repo": repo,
227
+ "repo_url": repo_url,
228
+ "branch": branch,
229
+ "project_path": path,
230
+ "env": env_map,
231
+ "serve_command": serve_command,
232
+ "release_command": release_command,
233
+ "build_command": build_command,
234
+ "python_version": python_version,
235
+ "dry_run": dry_run,
236
+ }
237
+ # A dot means "that's the whole domain"; a bare word is a pyvolt
238
+ # subdomain prefix. Prefixes can't contain dots, so this never guesses.
239
+ payload["custom_domain" if "." in domain else "domain_prefix"] = domain
240
+ r = api.post("/v1/apps", ok=(200, 201, 400, 402, 409), json=payload)
241
+ if r.status_code in (400, 402, 409):
242
+ raise fail(r.json()["detail"])
243
+ body = r.json()
244
+ if _emit(body):
245
+ return
246
+
247
+ plan = body["plan"]
248
+ t = _table("FIELD", "VALUE")
249
+ t.add_row("domain", plan["domain"])
250
+ t.add_row("repo", f"{plan['repo_url']} @ {plan['branch']}")
251
+ if plan["framework"]:
252
+ t.add_row("framework", plan["framework"])
253
+ t.add_row("protocol", plan["app_protocol"].upper())
254
+ t.add_row("package manager", plan["package_manager"])
255
+ if plan["python_version"]:
256
+ t.add_row("python", plan["python_version"])
257
+ if plan["serve_command"]:
258
+ t.add_row("serve", plan["serve_command"])
259
+ if plan["release_command"]:
260
+ t.add_row("release", plan["release_command"])
261
+ if plan["build_command"]:
262
+ t.add_row("build", plan["build_command"])
263
+ if plan["frontend_dir"]:
264
+ t.add_row("frontend", f"{plan['frontend_dir']}/ (passthrough: {plan['frontend_passthrough'] or 'none'})")
265
+ console.print(t)
266
+ if plan["evidence"]:
267
+ console.print(f"[dim]Found: {', '.join(plan['evidence'])}[/dim]")
268
+ for w in plan["warnings"]:
269
+ console.print(f"[yellow]⚠[/yellow] {w}")
270
+
271
+ if dry_run:
272
+ console.print("[dim]Dry run — nothing was created.[/dim]")
273
+ return
274
+ created = body["app"]
275
+ if not body["scaffolded"]:
276
+ err_detail = body["scaffold_error"] or "unknown error"
277
+ console.print(f"[red]✗[/red] App row created but scaffolding failed: {err_detail}")
278
+ console.print(f" Retry from the dashboard: {created['dashboard_url']}")
279
+ raise typer.Exit(1)
280
+ if body["deploy_key_ok"] is False:
281
+ console.print(
282
+ "[yellow]⚠[/yellow] Deploy key push failed — deploys will fail at clone. "
283
+ "Grant the Pyvolt GitHub App access to the repo, then redeploy."
284
+ )
285
+ console.print(f"[green]✓[/green] Created [bold]{created['domain']}[/bold]")
286
+ console.print(f" Deploy: [bold]pyvolt deploy {created['domain']} --follow[/bold]")
287
+ console.print(f" Dashboard: {created['dashboard_url']}")
288
+
289
+
186
290
  @app.command()
187
291
  def status(app_name: str = typer.Argument(..., metavar="DOMAIN")):
188
292
  """Show one app's details: status, URLs, repo and branch."""
@@ -202,7 +306,7 @@ def status(app_name: str = typer.Argument(..., metavar="DOMAIN")):
202
306
 
203
307
  # ---- deployments --------------------------------------------------------------
204
308
 
205
- TERMINAL = ("succeeded", "failed")
309
+ TERMINAL = ("success", "failed")
206
310
 
207
311
 
208
312
  def _follow(api: Api, deployment_id: int) -> str:
@@ -369,6 +473,28 @@ def logs(
369
473
  console.out(line, highlight=False)
370
474
 
371
475
 
476
+ @app.command()
477
+ def metrics(server: str = typer.Argument(..., metavar="SERVER")):
478
+ """Latest CPU / memory / disk / network snapshot for a server."""
479
+ api = Api()
480
+ srv = api.resolve_server(server)
481
+ m = api.get(f"/v1/servers/{srv['id']}/metrics")
482
+ if _emit(m):
483
+ return
484
+ if m["at"] is None:
485
+ console.print("[yellow]No metrics collected yet[/yellow] — they land a few minutes after provisioning.")
486
+ return
487
+ pct = lambda v: f"{v:.0f}%" if v is not None else "-" # noqa: E731
488
+ t = _table("METRIC", "VALUE")
489
+ t.add_row("CPU", pct(m["cpu_percent"]))
490
+ t.add_row("Memory", pct(m["memory_percent"]))
491
+ t.add_row("Disk", pct(m["disk_percent"]))
492
+ t.add_row("Net in", f"{m['net_in_bytes'] / 1e6:.1f} MB")
493
+ t.add_row("Net out", f"{m['net_out_bytes'] / 1e6:.1f} MB")
494
+ console.print(t)
495
+ console.print(f"[dim]as of {m['at']}[/dim]")
496
+
497
+
372
498
  # ---- fail2ban -------------------------------------------------------------------
373
499
 
374
500
  @app.command()
@@ -1,4 +0,0 @@
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.3.0"
File without changes
File without changes