offbox 0.1.0__py3-none-any.whl

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.
Files changed (72) hide show
  1. offbox/__init__.py +2 -0
  2. offbox/__main__.py +4 -0
  3. offbox/_data/host/cloud-init.yaml.j2 +15 -0
  4. offbox/_data/host/host-init.sh +230 -0
  5. offbox/_data/images/universal/Dockerfile +101 -0
  6. offbox/_data/images/universal/offbox-init +75 -0
  7. offbox/_data/images/universal/offbox-run +131 -0
  8. offbox/_data/providers/gcp/.terraform.lock.hcl +52 -0
  9. offbox/_data/providers/gcp/main.tf +67 -0
  10. offbox/_data/providers/gcp/outputs.tf +7 -0
  11. offbox/_data/providers/gcp/provider.toml +9 -0
  12. offbox/_data/providers/gcp/variables.tf +33 -0
  13. offbox/_data/providers/gcp/versions.tf +12 -0
  14. offbox/_data/providers/hetzner/.terraform.lock.hcl +51 -0
  15. offbox/_data/providers/hetzner/main.tf +32 -0
  16. offbox/_data/providers/hetzner/outputs.tf +7 -0
  17. offbox/_data/providers/hetzner/provider.toml +9 -0
  18. offbox/_data/providers/hetzner/variables.tf +28 -0
  19. offbox/_data/providers/hetzner/versions.tf +12 -0
  20. offbox/_data/providers/ssh/provider.toml +4 -0
  21. offbox/cli/__init__.py +117 -0
  22. offbox/cli/config.py +39 -0
  23. offbox/cli/env.py +109 -0
  24. offbox/cli/exec.py +50 -0
  25. offbox/cli/host.py +313 -0
  26. offbox/cli/misc.py +132 -0
  27. offbox/cli/ports.py +44 -0
  28. offbox/config.py +189 -0
  29. offbox/console.py +63 -0
  30. offbox/doctor.py +174 -0
  31. offbox/drivers/__init__.py +0 -0
  32. offbox/drivers/base.py +40 -0
  33. offbox/drivers/none.py +114 -0
  34. offbox/drivers/tofu.py +59 -0
  35. offbox/errors.py +45 -0
  36. offbox/execute.py +772 -0
  37. offbox/host/__init__.py +89 -0
  38. offbox/host/activity.py +58 -0
  39. offbox/host/auto.py +322 -0
  40. offbox/host/cloudinit.py +44 -0
  41. offbox/host/create.py +198 -0
  42. offbox/host/destroy.py +151 -0
  43. offbox/host/init.py +196 -0
  44. offbox/host/ready.py +106 -0
  45. offbox/host/timer.py +227 -0
  46. offbox/host/usage.py +123 -0
  47. offbox/host/warnings.py +61 -0
  48. offbox/locking.py +27 -0
  49. offbox/logging.py +154 -0
  50. offbox/logs.py +135 -0
  51. offbox/naming.py +67 -0
  52. offbox/paths.py +55 -0
  53. offbox/ports.py +610 -0
  54. offbox/project.py +187 -0
  55. offbox/providers.py +117 -0
  56. offbox/py.typed +0 -0
  57. offbox/runner.py +291 -0
  58. offbox/runtime.py +887 -0
  59. offbox/setup.py +181 -0
  60. offbox/share.py +286 -0
  61. offbox/ssh.py +179 -0
  62. offbox/state.py +157 -0
  63. offbox/status.py +378 -0
  64. offbox/sync.py +380 -0
  65. offbox/timings.py +69 -0
  66. offbox/tools.py +227 -0
  67. offbox/uninstall.py +114 -0
  68. offbox-0.1.0.dist-info/METADATA +140 -0
  69. offbox-0.1.0.dist-info/RECORD +72 -0
  70. offbox-0.1.0.dist-info/WHEEL +4 -0
  71. offbox-0.1.0.dist-info/entry_points.txt +2 -0
  72. offbox-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,67 @@
1
+ locals {
2
+ region = replace(var.region, "/-[a-z]$/", "")
3
+ }
4
+
5
+ resource "google_compute_network" "this" {
6
+ name = var.name
7
+ description = "Offbox host ${var.name}"
8
+ auto_create_subnetworks = false
9
+ routing_mode = "REGIONAL"
10
+ }
11
+
12
+ resource "google_compute_subnetwork" "this" {
13
+ name = var.name
14
+ region = local.region
15
+ network = google_compute_network.this.id
16
+ ip_cidr_range = "10.90.0.0/24"
17
+ }
18
+
19
+ resource "google_compute_firewall" "this" {
20
+ name = "${var.name}-ssh"
21
+ description = "Offbox SSH and ICMP for ${var.name}"
22
+ network = google_compute_network.this.name
23
+ direction = "INGRESS"
24
+ source_ranges = ["0.0.0.0/0"]
25
+ target_tags = [var.name]
26
+
27
+ allow {
28
+ protocol = "tcp"
29
+ ports = ["22"]
30
+ }
31
+
32
+ allow {
33
+ protocol = "icmp"
34
+ }
35
+ }
36
+
37
+ resource "google_compute_instance" "this" {
38
+ name = var.name
39
+ machine_type = var.size
40
+ zone = var.region
41
+ labels = var.labels
42
+ tags = [var.name]
43
+ deletion_protection = false
44
+
45
+ boot_disk {
46
+ auto_delete = true
47
+
48
+ initialize_params {
49
+ image = var.image
50
+ size = 50
51
+ type = "pd-balanced"
52
+ labels = var.labels
53
+ }
54
+ }
55
+
56
+ network_interface {
57
+ subnetwork = google_compute_subnetwork.this.id
58
+
59
+ access_config {}
60
+ }
61
+
62
+ metadata = {
63
+ user-data = var.user_data
64
+ enable-oslogin = "FALSE"
65
+ block-project-ssh-keys = "TRUE"
66
+ }
67
+ }
@@ -0,0 +1,7 @@
1
+ output "ip" {
2
+ value = google_compute_instance.this.network_interface[0].access_config[0].nat_ip
3
+ }
4
+
5
+ output "ssh_user" {
6
+ value = "offbox"
7
+ }
@@ -0,0 +1,9 @@
1
+ name = "gcp"
2
+ description = "Google Compute Engine Ubuntu host"
3
+ driver = "tofu"
4
+ credentials_env = ["GOOGLE_PROJECT"]
5
+
6
+ [defaults]
7
+ size = "e2-standard-4"
8
+ region = "us-east4-c"
9
+ image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
@@ -0,0 +1,33 @@
1
+ variable "name" {
2
+ type = string
3
+ }
4
+
5
+ variable "size" {
6
+ type = string
7
+ }
8
+
9
+ variable "region" {
10
+ type = string
11
+
12
+ validation {
13
+ condition = can(regex("^[a-z]+-[a-z]+[0-9]+-[a-z]$", var.region))
14
+ error_message = "The GCP region setting must be a zone, for example us-east4-c."
15
+ }
16
+ }
17
+
18
+ variable "image" {
19
+ type = string
20
+ }
21
+
22
+ variable "ssh_public_key" {
23
+ type = string
24
+ }
25
+
26
+ variable "user_data" {
27
+ type = string
28
+ sensitive = true
29
+ }
30
+
31
+ variable "labels" {
32
+ type = map(string)
33
+ }
@@ -0,0 +1,12 @@
1
+ terraform {
2
+ required_version = ">= 1.6"
3
+
4
+ required_providers {
5
+ google = {
6
+ source = "hashicorp/google"
7
+ version = ">= 8.7, < 8.8"
8
+ }
9
+ }
10
+ }
11
+
12
+ provider "google" {}
@@ -0,0 +1,51 @@
1
+ # Provider checksums for Terraform (registry.terraform.io) and OpenTofu (registry.opentofu.org).
2
+ # Each tool verifies its own registry entry; init drops the other entry from the host workspace copy.
3
+ # Regenerate both with "providers lock" for linux_amd64, linux_arm64, darwin_amd64 and darwin_arm64.
4
+
5
+ provider "registry.terraform.io/hetznercloud/hcloud" {
6
+ version = "1.68.0"
7
+ constraints = ">= 1.68.0, < 1.69.0"
8
+ hashes = [
9
+ "h1:2MU8gDe67KjqALpvzzRfWONH2c/8djL+yxTnym9/FjY=",
10
+ "h1:BrgHGuh9WEMd8CQefgdSqhcePf4CZPdovwlHNdo3h70=",
11
+ "h1:KOFp1JbzZ6Xj2K80QL7HGJM6oG+oEo7tx3lIx3d5POM=",
12
+ "h1:oxpWvl0qfZplr759glEx537EqkkAgtagBtNsI8V13H8=",
13
+ "zh:0c3c2eb5bad6e2853da46dd83ed9c93c547b79f2abe977d492da9cf9d7236a62",
14
+ "zh:0ccae6bb2daee7c30b55672645b31c50920839a8aadac502d187f105352c1b37",
15
+ "zh:11559d821030e5f4657b81153419f3c5e4f6389e8cc6f8086db2ebeb7aade6bc",
16
+ "zh:164012cca257951da2870e9a0a0eb0be2ebde26fd27e30081c8d7e8729467a6e",
17
+ "zh:1fc7fea548dc8a0211ee832b0f1be489301f431cdfb153effd2fda7f63c02073",
18
+ "zh:73855025066ec55230559296b5c92d441b1b6dc2ce27a1102ab1e17f60cdcf0e",
19
+ "zh:78bfcca5d613f3285c2e326e92865d08e24d0574e29201ce9c1df612aa912a10",
20
+ "zh:84d6972ef2d6e03dc416ab34c8e266e0958ff390c7ad87761180706658b31614",
21
+ "zh:a04f8e12811e000657151cd6eed5eb80ae1042b8123bfea28343147ceda40f96",
22
+ "zh:b825f6500592d30004b6de8547ca1fa7841e16da0571cee8a67782cb84ec65cd",
23
+ "zh:d6bc5f6f27453248889e5df967111a187405f1434175a90221853226d47484af",
24
+ "zh:e052ad7a40cf1f63588c2b435ce7a9baca5ef0e1f9dcb321a06756eb3d38751a",
25
+ "zh:ea1497399246a51f16e3ff4a13dfba4c95a49997c2da7850703a086e211b7435",
26
+ ]
27
+ }
28
+
29
+ provider "registry.opentofu.org/hetznercloud/hcloud" {
30
+ version = "1.68.0"
31
+ constraints = ">= 1.68.0, < 1.69.0"
32
+ hashes = [
33
+ "h1:2MU8gDe67KjqALpvzzRfWONH2c/8djL+yxTnym9/FjY=",
34
+ "h1:BrgHGuh9WEMd8CQefgdSqhcePf4CZPdovwlHNdo3h70=",
35
+ "h1:KOFp1JbzZ6Xj2K80QL7HGJM6oG+oEo7tx3lIx3d5POM=",
36
+ "h1:oxpWvl0qfZplr759glEx537EqkkAgtagBtNsI8V13H8=",
37
+ "zh:0c3c2eb5bad6e2853da46dd83ed9c93c547b79f2abe977d492da9cf9d7236a62",
38
+ "zh:0ccae6bb2daee7c30b55672645b31c50920839a8aadac502d187f105352c1b37",
39
+ "zh:11559d821030e5f4657b81153419f3c5e4f6389e8cc6f8086db2ebeb7aade6bc",
40
+ "zh:164012cca257951da2870e9a0a0eb0be2ebde26fd27e30081c8d7e8729467a6e",
41
+ "zh:1fc7fea548dc8a0211ee832b0f1be489301f431cdfb153effd2fda7f63c02073",
42
+ "zh:73855025066ec55230559296b5c92d441b1b6dc2ce27a1102ab1e17f60cdcf0e",
43
+ "zh:78bfcca5d613f3285c2e326e92865d08e24d0574e29201ce9c1df612aa912a10",
44
+ "zh:84d6972ef2d6e03dc416ab34c8e266e0958ff390c7ad87761180706658b31614",
45
+ "zh:a04f8e12811e000657151cd6eed5eb80ae1042b8123bfea28343147ceda40f96",
46
+ "zh:b825f6500592d30004b6de8547ca1fa7841e16da0571cee8a67782cb84ec65cd",
47
+ "zh:d6bc5f6f27453248889e5df967111a187405f1434175a90221853226d47484af",
48
+ "zh:e052ad7a40cf1f63588c2b435ce7a9baca5ef0e1f9dcb321a06756eb3d38751a",
49
+ "zh:ea1497399246a51f16e3ff4a13dfba4c95a49997c2da7850703a086e211b7435",
50
+ ]
51
+ }
@@ -0,0 +1,32 @@
1
+ resource "hcloud_firewall" "this" {
2
+ name = var.name
3
+ labels = var.labels
4
+
5
+ rule {
6
+ direction = "in"
7
+ protocol = "tcp"
8
+ port = "22"
9
+ source_ips = ["0.0.0.0/0", "::/0"]
10
+ }
11
+
12
+ rule {
13
+ direction = "in"
14
+ protocol = "icmp"
15
+ source_ips = ["0.0.0.0/0", "::/0"]
16
+ }
17
+ }
18
+
19
+ resource "hcloud_server" "this" {
20
+ name = var.name
21
+ image = var.image
22
+ server_type = var.size
23
+ location = var.region
24
+ user_data = var.user_data
25
+ labels = var.labels
26
+ firewall_ids = [hcloud_firewall.this.id]
27
+
28
+ public_net {
29
+ ipv4_enabled = true
30
+ ipv6_enabled = true
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ output "ip" {
2
+ value = hcloud_server.this.ipv4_address
3
+ }
4
+
5
+ output "ssh_user" {
6
+ value = "offbox"
7
+ }
@@ -0,0 +1,9 @@
1
+ name = "hetzner"
2
+ description = "Hetzner Cloud Ubuntu host"
3
+ driver = "tofu"
4
+ credentials_env = ["HCLOUD_TOKEN"]
5
+
6
+ [defaults]
7
+ size = "ccx33"
8
+ region = "fsn1"
9
+ image = "ubuntu-24.04"
@@ -0,0 +1,28 @@
1
+ variable "name" {
2
+ type = string
3
+ }
4
+
5
+ variable "size" {
6
+ type = string
7
+ }
8
+
9
+ variable "region" {
10
+ type = string
11
+ }
12
+
13
+ variable "image" {
14
+ type = string
15
+ }
16
+
17
+ variable "ssh_public_key" {
18
+ type = string
19
+ }
20
+
21
+ variable "user_data" {
22
+ type = string
23
+ sensitive = true
24
+ }
25
+
26
+ variable "labels" {
27
+ type = map(string)
28
+ }
@@ -0,0 +1,12 @@
1
+ terraform {
2
+ required_version = ">= 1.6"
3
+
4
+ required_providers {
5
+ hcloud = {
6
+ source = "hetznercloud/hcloud"
7
+ version = ">= 1.68, < 1.69"
8
+ }
9
+ }
10
+ }
11
+
12
+ provider "hcloud" {}
@@ -0,0 +1,4 @@
1
+ name = "ssh"
2
+ description = "An existing Ubuntu host reached over SSH"
3
+ driver = "none"
4
+ credentials_env = []
offbox/cli/__init__.py ADDED
@@ -0,0 +1,117 @@
1
+ import logging
2
+ from typing import Annotated
3
+
4
+ import typer
5
+ from typer._click import ClickException, Context
6
+ from typer.core import TyperGroup
7
+ from typer.exceptions import Abort, Exit
8
+
9
+ from offbox import __version__, console
10
+ from offbox.cli.config import register as register_config_command
11
+ from offbox.cli.env import register as register_env_commands
12
+ from offbox.cli.exec import register as register_exec_command
13
+ from offbox.cli.host import app as host_app
14
+ from offbox.cli.misc import register as register_misc_commands
15
+ from offbox.cli.ports import register as register_ports_command
16
+ from offbox.doctor import diagnose
17
+ from offbox.errors import OffboxError
18
+ from offbox.logging import configure, redact, register_exec_secrets
19
+ from offbox.paths import Paths
20
+ from offbox.runner import SubprocessRunner
21
+ from offbox.timings import collect
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ErrorGroup(TyperGroup):
27
+ def make_context(self, info_name: str | None, args: list[str], parent: Context | None = None, **extra: object) -> Context:
28
+ paths = Paths.discover()
29
+ log_path = paths.log_file()
30
+ try:
31
+ register_exec_secrets(args)
32
+ paths.secure_roots()
33
+ configure(paths, [info_name or "offbox", *args])
34
+ return super().make_context(info_name, args, parent, **extra)
35
+ except ClickException as exc:
36
+ console.error(exc.format_message(), "Run offbox --help for valid arguments.")
37
+ raise Exit(exc.exit_code) from exc
38
+ except OSError as exc:
39
+ console.error(f"Cannot initialize log file {log_path}", "Check XDG_STATE_HOME permissions and available disk space.")
40
+ raise Exit(1) from exc
41
+
42
+ def invoke(self, ctx: Context) -> object:
43
+ with collect(ctx.params.get("timings") is True):
44
+ return self._invoke_with_errors(ctx)
45
+
46
+ def _invoke_with_errors(self, ctx: Context) -> object:
47
+ log_path = Paths.discover().log_file()
48
+ try:
49
+ return super().invoke(ctx)
50
+ except OffboxError as exc:
51
+ logger.exception("offbox failure")
52
+ console.error(redact(exc.message), redact(exc.fix) if exc.fix else "Check the log and retry.")
53
+ raise Exit(exc.exit_code) from exc
54
+ except ClickException as exc:
55
+ console.error(exc.format_message(), "Run offbox --help for valid arguments.")
56
+ raise Exit(exc.exit_code) from exc
57
+ except (Exit, Abort):
58
+ raise
59
+ except KeyboardInterrupt as exc:
60
+ logger.warning("offbox interrupted", exc_info=True)
61
+ console.warn("Interrupted")
62
+ raise Exit(130) from exc
63
+ except Exception as exc:
64
+ logger.exception("unexpected failure")
65
+ console.error(f"unexpected error, see {log_path}", "Check the log and report this error.")
66
+ raise Exit(1) from exc
67
+
68
+
69
+ app = typer.Typer(no_args_is_help=True, add_completion=False, cls=ErrorGroup)
70
+ app.add_typer(host_app, name="host")
71
+ register_env_commands(app)
72
+ register_exec_command(app)
73
+ register_ports_command(app)
74
+ register_misc_commands(app)
75
+ register_config_command(app)
76
+
77
+
78
+ def _version_callback(value: bool) -> None:
79
+ if value:
80
+ console.version(__version__)
81
+ raise typer.Exit()
82
+
83
+
84
+ @app.callback()
85
+ def main(
86
+ ctx: typer.Context,
87
+ version: Annotated[
88
+ bool, typer.Option("--version", callback=_version_callback, is_eager=True, help="Show the version and exit.")
89
+ ] = False,
90
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Echo commands with secrets redacted.")] = False,
91
+ timings: Annotated[bool, typer.Option("--timings", help="Show stage durations on stderr; excludes CLI startup.")] = False,
92
+ ) -> None:
93
+ ctx.obj = SubprocessRunner(verbose=verbose)
94
+
95
+
96
+ @app.command()
97
+ def version() -> None:
98
+ """Show the version and exit."""
99
+ console.version(__version__)
100
+
101
+
102
+ @app.command()
103
+ def doctor(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
104
+ """Check local tools, configuration and default host connectivity."""
105
+ runner = ctx.obj
106
+ if not isinstance(runner, SubprocessRunner):
107
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
108
+ report = diagnose(Paths.discover(), runner)
109
+ if json:
110
+ console.json_out(report)
111
+ else:
112
+ for check in report.checks:
113
+ if check.ok:
114
+ console.success(check.message)
115
+ else:
116
+ console.error(check.message, check.fix)
117
+ raise typer.Exit(report.exit_code)
offbox/cli/config.py ADDED
@@ -0,0 +1,39 @@
1
+ import json as jsonlib
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+ from offbox import console
7
+ from offbox.errors import OffboxError
8
+ from offbox.naming import find_worktree
9
+ from offbox.paths import Paths
10
+ from offbox.project import report
11
+ from offbox.runner import SubprocessRunner
12
+
13
+
14
+ def config(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
15
+ """Show this worktree's effective settings, where each comes from and its OFFBOX_* variable."""
16
+ runner: object = ctx.find_root().obj
17
+ if not isinstance(runner, SubprocessRunner):
18
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
19
+ paths = Paths.discover()
20
+ result = report(paths, find_worktree(runner).root)
21
+ if json:
22
+ console.json_out(result)
23
+ return
24
+ files = result.files
25
+ console.output("Sources, later wins: default < user < project < local < env")
26
+ for name, path, suffix in (("user", files.user, " [project]"), ("project", files.project, ""), ("local", files.local, "")):
27
+ console.output(f" {name:<8} {path}{suffix}{'' if path.is_file() else ' (not present)'}")
28
+ console.output("")
29
+ rows = [
30
+ (setting.key, "unset" if setting.value is None else jsonlib.dumps(setting.value), setting.source, setting.env)
31
+ for setting in result.settings
32
+ ]
33
+ widths = [max(len(row[column]) for row in rows) for column in range(3)]
34
+ for key, value, source, env in rows:
35
+ console.output(f"{key:<{widths[0]}} {value:<{widths[1]}} {source:<{widths[2]}} {env}")
36
+
37
+
38
+ def register(app: typer.Typer) -> None:
39
+ app.command()(config)
offbox/cli/env.py ADDED
@@ -0,0 +1,109 @@
1
+ import sys
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+ from offbox import console
7
+ from offbox.errors import OffboxError, PreconditionError
8
+ from offbox.paths import Paths
9
+ from offbox.runner import SubprocessRunner
10
+ from offbox.runtime import Runtime
11
+
12
+
13
+ def _runtime(ctx: typer.Context) -> Runtime:
14
+ value: object = ctx.find_root().obj
15
+ if not isinstance(value, SubprocessRunner):
16
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
17
+ return Runtime(Paths.discover(), value)
18
+
19
+
20
+ def _confirm(message: str, yes: bool, fix: str) -> None:
21
+ if yes:
22
+ return
23
+ if not sys.stdin.isatty():
24
+ raise PreconditionError("This operation requires --yes without a TTY", fix)
25
+ if not typer.confirm(message):
26
+ raise PreconditionError("Operation cancelled", "Run the command again when ready.")
27
+
28
+
29
+ def up(
30
+ ctx: typer.Context,
31
+ host: Annotated[str | None, typer.Option("--host", help="Host for a new environment. Default: the default host.")] = None,
32
+ fresh: Annotated[
33
+ bool, typer.Option("--fresh", help="Recreate the container and delete its inner Docker data; source files are kept.")
34
+ ] = False,
35
+ no_stack: Annotated[bool, typer.Option("--no-stack", help="Skip starting the project's Compose stack.")] = False,
36
+ yes: Annotated[bool, typer.Option("--yes", help="Confirm --fresh without a prompt.")] = False,
37
+ ) -> None:
38
+ """Create or start this worktree's isolated environment."""
39
+ if fresh:
40
+ _confirm(
41
+ "Recreate this environment and delete its inner Docker data (images, volumes, containers)?",
42
+ yes,
43
+ "Run offbox up --fresh --yes.",
44
+ )
45
+ _runtime(ctx).up(host_name=host, fresh=fresh, no_stack=no_stack)
46
+
47
+
48
+ def stop(ctx: typer.Context) -> None:
49
+ """Stop this environment while retaining its data."""
50
+ _runtime(ctx).stop()
51
+
52
+
53
+ def rm(ctx: typer.Context, yes: Annotated[bool, typer.Option("--yes", help="Confirm without a prompt.")] = False) -> None:
54
+ """Remove this worktree's remote environment and Docker data."""
55
+ _confirm("Remove this worktree's remote environment and all its Docker data?", yes, "Run offbox rm --yes to confirm removal.")
56
+ _runtime(ctx).remove()
57
+
58
+
59
+ def list_envs(ctx: typer.Context, json: Annotated[bool, typer.Option("--json", help="Emit a typed JSON report.")] = False) -> None:
60
+ """List environments and their live container status."""
61
+ report = _runtime(ctx).report()
62
+ if json:
63
+ console.json_out(report)
64
+ elif not report.envs:
65
+ console.info("No environments registered")
66
+ else:
67
+ for env in report.envs:
68
+ used = f" last used {env.last_used.astimezone().strftime('%Y-%m-%d %H:%M')}" if env.last_used else ""
69
+ console.info(f"{env.name} {env.host} {env.status} {env.worktree}{used}")
70
+ if env.fix:
71
+ console.warn(f"Fix: {env.fix}")
72
+
73
+
74
+ def shell(ctx: typer.Context) -> None:
75
+ """Open an interactive shell inside this environment."""
76
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
77
+ raise PreconditionError("An environment shell requires a TTY", "Run offbox shell in an interactive terminal.")
78
+ raise typer.Exit(_runtime(ctx).shell())
79
+
80
+
81
+ def gc(
82
+ ctx: typer.Context,
83
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show the plan without changing anything.")] = False,
84
+ yes: Annotated[bool, typer.Option("--yes", help="Apply the plan without a prompt.")] = False,
85
+ ) -> None:
86
+ """Remove stale or orphan environments and prune unused inner Docker data."""
87
+ runtime = _runtime(ctx)
88
+ plan = runtime.gc_plan()
89
+ for target in plan.targets:
90
+ console.info(f"Remove {target.host}/{target.worktree.name}: {target.reason}")
91
+ for target in plan.running:
92
+ console.info(f"Prune unused Docker data in {target.host}/{target.worktree.name}")
93
+ if dry_run:
94
+ console.info("Dry run; no resources changed")
95
+ return
96
+ if not plan.targets and not plan.running:
97
+ console.info("Nothing to clean up")
98
+ return
99
+ _confirm("Apply this garbage collection plan?", yes, "Run offbox gc --dry-run to review, then offbox gc --yes.")
100
+ runtime.collect(plan)
101
+
102
+
103
+ def register(app: typer.Typer) -> None:
104
+ app.command()(up)
105
+ app.command()(stop)
106
+ app.command()(rm)
107
+ app.command("list")(list_envs)
108
+ app.command()(shell)
109
+ app.command()(gc)
offbox/cli/exec.py ADDED
@@ -0,0 +1,50 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+ from pydantic import TypeAdapter
5
+ from typer._click import Context
6
+ from typer.core import TyperCommand
7
+
8
+ from offbox.errors import OffboxError, UsageError
9
+ from offbox.execute import ExecOptions, Executor, parse_env
10
+ from offbox.paths import Paths
11
+ from offbox.runner import SubprocessRunner
12
+
13
+
14
+ class ExecCommand(TyperCommand):
15
+ def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
16
+ if "--" in args:
17
+ index = args.index("--")
18
+ ctx.meta["offbox_exec_command"] = tuple(args[index + 1 :])
19
+ args = args[:index]
20
+ return super().parse_args(ctx, args)
21
+
22
+ def collect_usage_pieces(self, ctx: Context) -> list[str]:
23
+ return [*super().collect_usage_pieces(ctx), "-- COMMAND [ARGS]..."]
24
+
25
+
26
+ def execute(
27
+ ctx: typer.Context,
28
+ no_sync: Annotated[bool, typer.Option("--no-sync", help="Skip the source sync flush before running.")] = False,
29
+ tty: Annotated[bool, typer.Option("--tty", help="Allocate a remote TTY. Default: when stdin and stdout are terminals.")] = False,
30
+ no_tty: Annotated[bool, typer.Option("--no-tty", help="Never allocate a remote TTY.")] = False,
31
+ stdin: Annotated[bool, typer.Option("--stdin", help="Forward local stdin to a non-TTY command.")] = False,
32
+ env: Annotated[list[str] | None, typer.Option("--env", help="Set NAME=value in the command environment; repeatable.")] = None,
33
+ ) -> None:
34
+ """Run a command after -- inside this worktree's environment."""
35
+ if tty and no_tty:
36
+ raise UsageError("--tty and --no-tty are mutually exclusive", "Choose one TTY mode.")
37
+ command = TypeAdapter(list[str]).validate_python(ctx.meta.get("offbox_exec_command", ()))
38
+ if not command:
39
+ raise UsageError("A command is required after --", "Run offbox exec -- COMMAND [ARGS...].")
40
+ runner: object = ctx.find_root().obj
41
+ if not isinstance(runner, SubprocessRunner):
42
+ raise OffboxError("Runner is unavailable", "Restart offbox.")
43
+ options = ExecOptions(
44
+ command=command, tty=True if tty else False if no_tty else None, no_sync=no_sync, stdin=stdin, overrides=parse_env(env or [])
45
+ )
46
+ raise typer.Exit(Executor(Paths.discover(), runner).execute(options))
47
+
48
+
49
+ def register(app: typer.Typer) -> None:
50
+ app.command("exec", cls=ExecCommand)(execute)