bitsreef-cli 0.1.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.
Files changed (39) hide show
  1. bitsreef_cli-0.1.0/PKG-INFO +143 -0
  2. bitsreef_cli-0.1.0/README.md +132 -0
  3. bitsreef_cli-0.1.0/bitsreef_cli/__init__.py +2 -0
  4. bitsreef_cli-0.1.0/bitsreef_cli/client.py +74 -0
  5. bitsreef_cli-0.1.0/bitsreef_cli/commands/__init__.py +0 -0
  6. bitsreef_cli-0.1.0/bitsreef_cli/commands/auth.py +91 -0
  7. bitsreef_cli-0.1.0/bitsreef_cli/commands/completion.py +29 -0
  8. bitsreef_cli-0.1.0/bitsreef_cli/commands/cron.py +166 -0
  9. bitsreef_cli-0.1.0/bitsreef_cli/commands/deploy.py +124 -0
  10. bitsreef_cli-0.1.0/bitsreef_cli/commands/domains.py +119 -0
  11. bitsreef_cli-0.1.0/bitsreef_cli/commands/env.py +138 -0
  12. bitsreef_cli-0.1.0/bitsreef_cli/commands/environments.py +132 -0
  13. bitsreef_cli-0.1.0/bitsreef_cli/commands/exec_cmd.py +27 -0
  14. bitsreef_cli-0.1.0/bitsreef_cli/commands/functions.py +186 -0
  15. bitsreef_cli-0.1.0/bitsreef_cli/commands/link.py +87 -0
  16. bitsreef_cli-0.1.0/bitsreef_cli/commands/logs.py +44 -0
  17. bitsreef_cli-0.1.0/bitsreef_cli/commands/metrics.py +50 -0
  18. bitsreef_cli-0.1.0/bitsreef_cli/commands/projects.py +89 -0
  19. bitsreef_cli-0.1.0/bitsreef_cli/commands/services.py +149 -0
  20. bitsreef_cli-0.1.0/bitsreef_cli/commands/templates.py +106 -0
  21. bitsreef_cli-0.1.0/bitsreef_cli/commands/up.py +195 -0
  22. bitsreef_cli-0.1.0/bitsreef_cli/commands/volumes.py +99 -0
  23. bitsreef_cli-0.1.0/bitsreef_cli/commands/webhooks.py +123 -0
  24. bitsreef_cli-0.1.0/bitsreef_cli/config.py +92 -0
  25. bitsreef_cli-0.1.0/bitsreef_cli/context.py +175 -0
  26. bitsreef_cli-0.1.0/bitsreef_cli/deploy_watch.py +87 -0
  27. bitsreef_cli-0.1.0/bitsreef_cli/log_stream.py +83 -0
  28. bitsreef_cli-0.1.0/bitsreef_cli/main.py +43 -0
  29. bitsreef_cli-0.1.0/bitsreef_cli/output.py +25 -0
  30. bitsreef_cli-0.1.0/bitsreef_cli/shell.py +136 -0
  31. bitsreef_cli-0.1.0/bitsreef_cli.egg-info/PKG-INFO +143 -0
  32. bitsreef_cli-0.1.0/bitsreef_cli.egg-info/SOURCES.txt +37 -0
  33. bitsreef_cli-0.1.0/bitsreef_cli.egg-info/dependency_links.txt +1 -0
  34. bitsreef_cli-0.1.0/bitsreef_cli.egg-info/entry_points.txt +2 -0
  35. bitsreef_cli-0.1.0/bitsreef_cli.egg-info/requires.txt +4 -0
  36. bitsreef_cli-0.1.0/bitsreef_cli.egg-info/top_level.txt +1 -0
  37. bitsreef_cli-0.1.0/pyproject.toml +23 -0
  38. bitsreef_cli-0.1.0/setup.cfg +4 -0
  39. bitsreef_cli-0.1.0/tests/test_cli.py +934 -0
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: bitsreef-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for BitsReef PaaS platform
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: httpx>=0.25
9
+ Requires-Dist: rich>=13.0
10
+ Requires-Dist: websocket-client>=1.6
11
+
12
+ # BitsReef CLI
13
+
14
+ `bitsreef` — deploy and manage BitsReef services from the command line.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ # One-liner (installs via pipx, or an isolated venv as fallback):
20
+ curl -fsSL https://raw.githubusercontent.com/cygnaragroup/bitsreef/main/cli/install.sh | sh
21
+
22
+ # ...or with pip / pipx directly:
23
+ pipx install bitsreef-cli # recommended — isolated, on PATH
24
+ pip install bitsreef-cli
25
+
26
+ # ...or from a checkout:
27
+ pip install -e cli/
28
+ ```
29
+
30
+ ### Shell completion
31
+
32
+ ```bash
33
+ eval "$(bitsreef completion bash)" # add to ~/.bashrc
34
+ eval "$(bitsreef completion zsh)" # add to ~/.zshrc
35
+ bitsreef completion fish > ~/.config/fish/completions/bitsreef.fish
36
+ ```
37
+
38
+ ## Getting started
39
+
40
+ ```bash
41
+ # Log in to BitsReef (the CLI talks to the hosted BitsReef API)
42
+ bitsreef auth login
43
+
44
+ # Deploy an image to a project in one shot, streaming to completion
45
+ bitsreef up --project 4 --name web --image nginx:latest --port 80 --follow
46
+ ```
47
+
48
+ `bitsreef up` creates the service if it doesn't exist (or updates its image if
49
+ it does), triggers a deployment, and with `--follow` streams the build/deploy
50
+ logs and exits non-zero if the deploy fails — so it drops straight into CI.
51
+
52
+ ## Linking (stop typing IDs)
53
+
54
+ Bind a directory to a project/service once, then omit IDs everywhere:
55
+
56
+ ```bash
57
+ bitsreef link --project demo --service web # writes ./.bitsreef.json
58
+ bitsreef logs # → the linked service's logs
59
+ bitsreef up -i nginx:1.27 --follow # redeploy the linked service
60
+ bitsreef deploy status # linked project's deployments
61
+ bitsreef link --show # what's linked here
62
+ bitsreef unlink # remove the link
63
+ ```
64
+
65
+ The link file (`.bitsreef.json`) is discovered by walking up from the current
66
+ directory, like `.git`. **Names or slugs work in place of numeric IDs**
67
+ everywhere (`bitsreef logs demo web`), and a default org is remembered after the
68
+ first pick so multi-org accounts stop re-prompting.
69
+
70
+ ## Commands
71
+
72
+ | Command | Purpose |
73
+ |---|---|
74
+ | `up` | Create-or-update a service from an image and deploy it (`--follow`, `--json`) |
75
+ | `exec` | Open an interactive shell (`/bin/sh`) in a running container |
76
+ | `link` / `unlink` | Bind this directory to a project/service (`--show`) |
77
+ | `auth` | `login`, `logout`, `whoami` |
78
+ | `projects` | `list`, `info`, `create` |
79
+ | `services` | `list`, `info`, `restart`, `stop`, `scale` |
80
+ | `deploy` | `up` (`--follow`), `rebuild`, `status`, `rollback` |
81
+ | `env` | `list`, `set`, `delete`, `reveal` |
82
+ | `domains` | `list`, `add`, `remove`, `verify` (custom domains) |
83
+ | `volumes` | `list`, `create`, `delete` |
84
+ | `metrics` | Current CPU / memory / network snapshot for a service |
85
+ | `cron` | `list`, `create`, `trigger`, `runs`, `delete` (scheduled jobs) |
86
+ | `webhooks` | `list`, `add`, `update`, `remove` (project event notifications) |
87
+ | `templates` | `list`, `info`, `deploy` (service templates) |
88
+ | `functions` | `list`, `create`, `invoke`, `history`, `update`, `delete` |
89
+ | `logs` | View service logs (`--tail`, `--follow` for a live tail) |
90
+ | `completion` | Print a shell-completion script (`bash`, `zsh`, `fish`) |
91
+
92
+ Run `bitsreef <command> --help` for full options.
93
+
94
+ ## CI / scripting / agents
95
+
96
+ Everything works non-interactively and can emit JSON:
97
+
98
+ ```bash
99
+ # Auth without a prompt — a token env var (nothing written to disk):
100
+ export BITSREEF_TOKEN=<access-token>
101
+
102
+ # ...or a piped password / a stored token:
103
+ echo "$PASS" | bitsreef auth login -u ci --password-stdin
104
+ bitsreef auth login --token "$BITSREEF_TOKEN"
105
+
106
+ # Machine-readable output (place --json before the command):
107
+ bitsreef --json services list demo | jq '.[].name'
108
+ bitsreef --json whoami
109
+ bitsreef up -p demo -n web -i img:tag --follow --json # exits non-zero on deploy failure
110
+ ```
111
+
112
+ `BITSREEF_TOKEN` (env) takes precedence over the config file. `--json` is
113
+ supported on the read commands (`whoami`, `projects/services list|info`,
114
+ `deploy status`, `env list`, `functions list|history`, `logs`) and `up`.
115
+
116
+ ## Configuration
117
+
118
+ Config and JWT tokens live in `~/.bitsreef/config.json` (created `0600`). The
119
+ access token is refreshed automatically on expiry. `BITSREEF_TOKEN` (env)
120
+ overrides the file when set. The API endpoint is built into the CLI — BitsReef
121
+ is a hosted service, so there is no server URL to configure.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ cd cli
127
+ pip install -e . pytest
128
+ pytest
129
+ ```
130
+
131
+ ### Releasing
132
+
133
+ Publishing is automated by `.github/workflows/cli-publish.yml` via PyPI
134
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token).
135
+ Bump `version` in `pyproject.toml` and `bitsreef_cli/__init__.py`, then tag:
136
+
137
+ ```bash
138
+ git tag cli-v0.1.0 && git push origin cli-v0.1.0
139
+ ```
140
+
141
+ The workflow verifies the tag matches the package version, builds the sdist +
142
+ wheel, `twine check`s them, and uploads to PyPI. It can also be run manually
143
+ from the Actions tab (`workflow_dispatch`).
@@ -0,0 +1,132 @@
1
+ # BitsReef CLI
2
+
3
+ `bitsreef` — deploy and manage BitsReef services from the command line.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # One-liner (installs via pipx, or an isolated venv as fallback):
9
+ curl -fsSL https://raw.githubusercontent.com/cygnaragroup/bitsreef/main/cli/install.sh | sh
10
+
11
+ # ...or with pip / pipx directly:
12
+ pipx install bitsreef-cli # recommended — isolated, on PATH
13
+ pip install bitsreef-cli
14
+
15
+ # ...or from a checkout:
16
+ pip install -e cli/
17
+ ```
18
+
19
+ ### Shell completion
20
+
21
+ ```bash
22
+ eval "$(bitsreef completion bash)" # add to ~/.bashrc
23
+ eval "$(bitsreef completion zsh)" # add to ~/.zshrc
24
+ bitsreef completion fish > ~/.config/fish/completions/bitsreef.fish
25
+ ```
26
+
27
+ ## Getting started
28
+
29
+ ```bash
30
+ # Log in to BitsReef (the CLI talks to the hosted BitsReef API)
31
+ bitsreef auth login
32
+
33
+ # Deploy an image to a project in one shot, streaming to completion
34
+ bitsreef up --project 4 --name web --image nginx:latest --port 80 --follow
35
+ ```
36
+
37
+ `bitsreef up` creates the service if it doesn't exist (or updates its image if
38
+ it does), triggers a deployment, and with `--follow` streams the build/deploy
39
+ logs and exits non-zero if the deploy fails — so it drops straight into CI.
40
+
41
+ ## Linking (stop typing IDs)
42
+
43
+ Bind a directory to a project/service once, then omit IDs everywhere:
44
+
45
+ ```bash
46
+ bitsreef link --project demo --service web # writes ./.bitsreef.json
47
+ bitsreef logs # → the linked service's logs
48
+ bitsreef up -i nginx:1.27 --follow # redeploy the linked service
49
+ bitsreef deploy status # linked project's deployments
50
+ bitsreef link --show # what's linked here
51
+ bitsreef unlink # remove the link
52
+ ```
53
+
54
+ The link file (`.bitsreef.json`) is discovered by walking up from the current
55
+ directory, like `.git`. **Names or slugs work in place of numeric IDs**
56
+ everywhere (`bitsreef logs demo web`), and a default org is remembered after the
57
+ first pick so multi-org accounts stop re-prompting.
58
+
59
+ ## Commands
60
+
61
+ | Command | Purpose |
62
+ |---|---|
63
+ | `up` | Create-or-update a service from an image and deploy it (`--follow`, `--json`) |
64
+ | `exec` | Open an interactive shell (`/bin/sh`) in a running container |
65
+ | `link` / `unlink` | Bind this directory to a project/service (`--show`) |
66
+ | `auth` | `login`, `logout`, `whoami` |
67
+ | `projects` | `list`, `info`, `create` |
68
+ | `services` | `list`, `info`, `restart`, `stop`, `scale` |
69
+ | `deploy` | `up` (`--follow`), `rebuild`, `status`, `rollback` |
70
+ | `env` | `list`, `set`, `delete`, `reveal` |
71
+ | `domains` | `list`, `add`, `remove`, `verify` (custom domains) |
72
+ | `volumes` | `list`, `create`, `delete` |
73
+ | `metrics` | Current CPU / memory / network snapshot for a service |
74
+ | `cron` | `list`, `create`, `trigger`, `runs`, `delete` (scheduled jobs) |
75
+ | `webhooks` | `list`, `add`, `update`, `remove` (project event notifications) |
76
+ | `templates` | `list`, `info`, `deploy` (service templates) |
77
+ | `functions` | `list`, `create`, `invoke`, `history`, `update`, `delete` |
78
+ | `logs` | View service logs (`--tail`, `--follow` for a live tail) |
79
+ | `completion` | Print a shell-completion script (`bash`, `zsh`, `fish`) |
80
+
81
+ Run `bitsreef <command> --help` for full options.
82
+
83
+ ## CI / scripting / agents
84
+
85
+ Everything works non-interactively and can emit JSON:
86
+
87
+ ```bash
88
+ # Auth without a prompt — a token env var (nothing written to disk):
89
+ export BITSREEF_TOKEN=<access-token>
90
+
91
+ # ...or a piped password / a stored token:
92
+ echo "$PASS" | bitsreef auth login -u ci --password-stdin
93
+ bitsreef auth login --token "$BITSREEF_TOKEN"
94
+
95
+ # Machine-readable output (place --json before the command):
96
+ bitsreef --json services list demo | jq '.[].name'
97
+ bitsreef --json whoami
98
+ bitsreef up -p demo -n web -i img:tag --follow --json # exits non-zero on deploy failure
99
+ ```
100
+
101
+ `BITSREEF_TOKEN` (env) takes precedence over the config file. `--json` is
102
+ supported on the read commands (`whoami`, `projects/services list|info`,
103
+ `deploy status`, `env list`, `functions list|history`, `logs`) and `up`.
104
+
105
+ ## Configuration
106
+
107
+ Config and JWT tokens live in `~/.bitsreef/config.json` (created `0600`). The
108
+ access token is refreshed automatically on expiry. `BITSREEF_TOKEN` (env)
109
+ overrides the file when set. The API endpoint is built into the CLI — BitsReef
110
+ is a hosted service, so there is no server URL to configure.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ cd cli
116
+ pip install -e . pytest
117
+ pytest
118
+ ```
119
+
120
+ ### Releasing
121
+
122
+ Publishing is automated by `.github/workflows/cli-publish.yml` via PyPI
123
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token).
124
+ Bump `version` in `pyproject.toml` and `bitsreef_cli/__init__.py`, then tag:
125
+
126
+ ```bash
127
+ git tag cli-v0.1.0 && git push origin cli-v0.1.0
128
+ ```
129
+
130
+ The workflow verifies the tag matches the package version, builds the sdist +
131
+ wheel, `twine check`s them, and uploads to PyPI. It can also be run manually
132
+ from the Actions tab (`workflow_dispatch`).
@@ -0,0 +1,2 @@
1
+ """BitsReef CLI — command-line interface for the BitsReef PaaS platform."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,74 @@
1
+ """HTTP client for BitsReef API with automatic token refresh."""
2
+ import sys
3
+
4
+ import click
5
+ import httpx
6
+
7
+ from . import config
8
+
9
+
10
+ class BitsReefClient:
11
+ """Authenticated HTTP client for the BitsReef API."""
12
+
13
+ def __init__(self):
14
+ self.base_url = config.get_api_url()
15
+ access, refresh = config.get_tokens()
16
+ self.access_token = access
17
+ self.refresh_token = refresh
18
+
19
+ def _headers(self) -> dict:
20
+ h = {"Content-Type": "application/json"}
21
+ if self.access_token:
22
+ h["Authorization"] = f"Bearer {self.access_token}"
23
+ return h
24
+
25
+ def _refresh(self) -> bool:
26
+ """Attempt to refresh the access token. Returns True on success."""
27
+ if not self.refresh_token:
28
+ return False
29
+ try:
30
+ resp = httpx.post(
31
+ f"{self.base_url}/account/token/refresh/",
32
+ json={"refresh": self.refresh_token},
33
+ timeout=10,
34
+ )
35
+ if resp.status_code == 200:
36
+ data = resp.json()
37
+ self.access_token = data["access"]
38
+ config.save_tokens(self.access_token, self.refresh_token)
39
+ return True
40
+ except httpx.HTTPError:
41
+ pass
42
+ return False
43
+
44
+ def request(self, method: str, path: str, **kwargs) -> httpx.Response:
45
+ """Make an authenticated request. Auto-refreshes token on 401."""
46
+ url = f"{self.base_url}{path}"
47
+ resp = httpx.request(method, url, headers=self._headers(), timeout=30, **kwargs)
48
+ if resp.status_code == 401 and self._refresh():
49
+ resp = httpx.request(method, url, headers=self._headers(), timeout=30, **kwargs)
50
+ if resp.status_code == 401:
51
+ click.secho("Session expired. Please run: bitsreef auth login", fg="red")
52
+ sys.exit(1)
53
+ return resp
54
+
55
+ def get(self, path: str, **kwargs) -> httpx.Response:
56
+ return self.request("GET", path, **kwargs)
57
+
58
+ def post(self, path: str, **kwargs) -> httpx.Response:
59
+ return self.request("POST", path, **kwargs)
60
+
61
+ def patch(self, path: str, **kwargs) -> httpx.Response:
62
+ return self.request("PATCH", path, **kwargs)
63
+
64
+ def delete(self, path: str, **kwargs) -> httpx.Response:
65
+ return self.request("DELETE", path, **kwargs)
66
+
67
+
68
+ def get_client() -> BitsReefClient:
69
+ """Return an authenticated client, exiting if not logged in."""
70
+ access, _ = config.get_tokens()
71
+ if not access:
72
+ click.secho("Not logged in. Run: bitsreef auth login", fg="red")
73
+ sys.exit(1)
74
+ return BitsReefClient()
File without changes
@@ -0,0 +1,91 @@
1
+ """Auth commands: login, logout, whoami."""
2
+ import sys
3
+
4
+ import click
5
+ import httpx
6
+ from rich.console import Console
7
+
8
+ from .. import config, output
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def auth():
15
+ """Authenticate with BitsReef."""
16
+
17
+
18
+ @auth.command()
19
+ @click.option("--username", "-u", default=None, help="Username (else prompted)")
20
+ @click.option("--password-stdin", "password_stdin", is_flag=True,
21
+ help="Read the password from stdin (non-interactive, for CI)")
22
+ @click.option("--token", default=None,
23
+ help="Store a pre-issued access token instead of logging in")
24
+ def login(username, password_stdin, token):
25
+ """Log in with username/password, or store a token non-interactively.
26
+
27
+ The CLI always talks to the hosted BitsReef API — there is no server URL to
28
+ set.
29
+
30
+ CI examples:
31
+ echo "$PASS" | bitsreef auth login -u ci --password-stdin
32
+ bitsreef auth login --token "$BITSREEF_TOKEN"
33
+ (Or skip login entirely and set BITSREEF_TOKEN.)
34
+ """
35
+ api_url = config.get_api_url()
36
+
37
+ # Non-interactive: store a pre-issued access token (no refresh token).
38
+ if token:
39
+ config.save_tokens(token, "")
40
+ console.print(f"[green]Token stored[/green] for {api_url}")
41
+ return
42
+
43
+ if not username:
44
+ username = click.prompt("Username")
45
+ if password_stdin:
46
+ password = sys.stdin.readline().rstrip("\n")
47
+ else:
48
+ password = click.prompt("Password", hide_input=True)
49
+
50
+ try:
51
+ resp = httpx.post(
52
+ f"{api_url}/account/token/",
53
+ json={"username": username, "password": password},
54
+ timeout=15,
55
+ )
56
+ except httpx.ConnectError:
57
+ console.print(f"[red]Cannot connect to {api_url}[/red]")
58
+ raise SystemExit(1)
59
+
60
+ if resp.status_code != 200:
61
+ console.print("[red]Login failed — check username/password.[/red]")
62
+ raise SystemExit(1)
63
+
64
+ data = resp.json()
65
+ config.save_tokens(data["access"], data["refresh"])
66
+ console.print(f"[green]Logged in as [bold]{username}[/bold][/green]")
67
+
68
+
69
+ @auth.command()
70
+ def logout():
71
+ """Log out and clear stored tokens."""
72
+ config.clear_tokens()
73
+ console.print("[green]Logged out.[/green]")
74
+
75
+
76
+ @auth.command()
77
+ def whoami():
78
+ """Show the current logged-in user."""
79
+ from ..client import get_client
80
+
81
+ client = get_client()
82
+ resp = client.get("/account/me/")
83
+ if resp.status_code != 200:
84
+ console.print("[red]Could not fetch user info.[/red]")
85
+ raise SystemExit(1)
86
+
87
+ user = resp.json()
88
+ if output.json_enabled():
89
+ output.emit(user)
90
+ return
91
+ console.print(f"[bold]{user['username']}[/bold] ({user['email']})")
@@ -0,0 +1,29 @@
1
+ """Shell-completion command — emit a completion script for bash/zsh/fish."""
2
+ import click
3
+ from click.shell_completion import get_completion_class
4
+
5
+ _INSTALL_HINTS = {
6
+ "bash": 'eval "$(bitsreef completion bash)" # add to ~/.bashrc',
7
+ "zsh": 'eval "$(bitsreef completion zsh)" # add to ~/.zshrc',
8
+ "fish": "bitsreef completion fish > ~/.config/fish/completions/bitsreef.fish",
9
+ }
10
+
11
+
12
+ @click.command()
13
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
14
+ def completion(shell):
15
+ """Print a shell-completion script for SHELL (bash, zsh, or fish).
16
+
17
+ Enable it by sourcing the output, e.g.:
18
+
19
+ eval "$(bitsreef completion bash)"
20
+
21
+ Add that line to your shell's rc file to make it permanent.
22
+ """
23
+ from ..main import cli as root
24
+
25
+ comp_cls = get_completion_class(shell)
26
+ if comp_cls is None:
27
+ raise click.ClickException(f"Completion is not supported for {shell}.")
28
+ comp = comp_cls(root, {}, "bitsreef", "_BITSREEF_COMPLETE")
29
+ click.echo(comp.source())
@@ -0,0 +1,166 @@
1
+ """Cron-job commands: list, create, trigger, runs, delete."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from ..client import get_client
7
+ from .. import context, output
8
+
9
+ console = Console()
10
+
11
+
12
+ def _service(client, service):
13
+ """Resolve --service (id/name) or the linked service."""
14
+ link = context.read_link()
15
+ return context.resolve_service(client, link.get("project_id"), service, link)
16
+
17
+
18
+ def _results(resp):
19
+ data = resp.json()
20
+ return data.get("results", []) if isinstance(data, dict) else data
21
+
22
+
23
+ def _job_id(client, service_id, value):
24
+ """Resolve a cron-job id from an id or a job name."""
25
+ if str(value).isdigit():
26
+ return int(value)
27
+ resp = client.get(f"/services/{service_id}/cron-jobs/")
28
+ for j in _results(resp):
29
+ if j.get("name") == value:
30
+ return j["id"]
31
+ raise click.ClickException(f"No cron job '{value}' on this service.")
32
+
33
+
34
+ @click.group()
35
+ def cron():
36
+ """Manage scheduled (cron) jobs for a service."""
37
+
38
+
39
+ @cron.command("list")
40
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
41
+ def list_jobs(service):
42
+ """List cron jobs on a service."""
43
+ client = get_client()
44
+ service_id = _service(client, service)
45
+ resp = client.get(f"/services/{service_id}/cron-jobs/")
46
+ if resp.status_code != 200:
47
+ console.print(f"[red]Failed to fetch cron jobs: {resp.text}[/red]")
48
+ raise SystemExit(1)
49
+
50
+ results = _results(resp)
51
+ if output.json_enabled():
52
+ output.emit(results)
53
+ return
54
+ if not results:
55
+ console.print("[dim]No cron jobs.[/dim]")
56
+ return
57
+
58
+ table = Table(title="Cron Jobs")
59
+ table.add_column("ID", style="dim")
60
+ table.add_column("Name", style="bold")
61
+ table.add_column("Schedule")
62
+ table.add_column("Enabled")
63
+ table.add_column("Last run")
64
+ table.add_column("Next run")
65
+ for j in results:
66
+ enabled = "[green]yes[/green]" if j.get("enabled") else "[red]no[/red]"
67
+ status = j.get("last_run_status")
68
+ last = (j.get("last_run") or "—")[:19]
69
+ if status:
70
+ last = f"{last} ({status})"
71
+ table.add_row(
72
+ str(j["id"]), j["name"], j.get("schedule", "—"),
73
+ enabled, last, (j.get("next_run") or "—")[:19],
74
+ )
75
+ console.print(table)
76
+
77
+
78
+ @cron.command("create")
79
+ @click.argument("name")
80
+ @click.option("--schedule", required=True, help="Cron expression, e.g. '0 * * * *'")
81
+ @click.option("--command", required=True, help="Command to run inside the container")
82
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
83
+ @click.option("--disabled", is_flag=True, help="Create the job disabled")
84
+ def create_job(name, schedule, command, service, disabled):
85
+ """Create a cron job on a service."""
86
+ client = get_client()
87
+ service_id = _service(client, service)
88
+ payload = {
89
+ "name": name, "schedule": schedule,
90
+ "command": command, "enabled": not disabled,
91
+ }
92
+ resp = client.post(f"/services/{service_id}/cron-jobs/", json=payload)
93
+ if resp.status_code == 201:
94
+ j = resp.json()
95
+ console.print(f"[green]Created[/green] [bold]{j['name']}[/bold] (id={j['id']}) next={j.get('next_run') or '—'}")
96
+ else:
97
+ console.print(f"[red]Failed: {resp.text}[/red]")
98
+
99
+
100
+ @cron.command("trigger")
101
+ @click.argument("job")
102
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
103
+ def trigger_job(job, service):
104
+ """Run a cron job now (by name or id)."""
105
+ client = get_client()
106
+ service_id = _service(client, service)
107
+ job_id = _job_id(client, service_id, job)
108
+ resp = client.post(f"/services/{service_id}/cron-jobs/{job_id}/trigger/")
109
+ if resp.status_code == 202:
110
+ console.print("[green]Triggered.[/green]")
111
+ else:
112
+ console.print(f"[red]Failed: {resp.text}[/red]")
113
+
114
+
115
+ @cron.command("runs")
116
+ @click.argument("job")
117
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
118
+ def job_runs(job, service):
119
+ """Show recent run history for a cron job (by name or id)."""
120
+ client = get_client()
121
+ service_id = _service(client, service)
122
+ job_id = _job_id(client, service_id, job)
123
+ resp = client.get(f"/services/{service_id}/cron-jobs/{job_id}/runs/")
124
+ if resp.status_code != 200:
125
+ console.print(f"[red]Failed to fetch runs: {resp.text}[/red]")
126
+ raise SystemExit(1)
127
+
128
+ results = _results(resp)
129
+ if output.json_enabled():
130
+ output.emit(results)
131
+ return
132
+ if not results:
133
+ console.print("[dim]No runs yet.[/dim]")
134
+ return
135
+
136
+ table = Table(title="Cron Runs")
137
+ table.add_column("ID", style="dim")
138
+ table.add_column("Status")
139
+ table.add_column("Exit")
140
+ table.add_column("Started")
141
+ table.add_column("Completed")
142
+ for r in results:
143
+ s = r.get("status", "")
144
+ color = "green" if s == "success" else "red" if s == "failed" else "yellow"
145
+ table.add_row(
146
+ str(r["id"]), f"[{color}]{s}[/{color}]",
147
+ str(r.get("exit_code") if r.get("exit_code") is not None else "—"),
148
+ (r.get("started_at") or "—")[:19], (r.get("completed_at") or "—")[:19],
149
+ )
150
+ console.print(table)
151
+
152
+
153
+ @cron.command("delete")
154
+ @click.argument("job")
155
+ @click.option("--service", "-s", default=None, help="Service id or name (else linked)")
156
+ @click.confirmation_option(prompt="Delete this cron job?")
157
+ def delete_job(job, service):
158
+ """Delete a cron job (by name or id)."""
159
+ client = get_client()
160
+ service_id = _service(client, service)
161
+ job_id = _job_id(client, service_id, job)
162
+ resp = client.delete(f"/services/{service_id}/cron-jobs/{job_id}/")
163
+ if resp.status_code == 204:
164
+ console.print("[green]Deleted.[/green]")
165
+ else:
166
+ console.print(f"[red]Failed: {resp.text}[/red]")