djangocloud-cli 0.1.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.
@@ -0,0 +1,31 @@
1
+ # Publishes to PyPI when a GitHub Release is published. Uses PyPI "trusted publishing" (OIDC), so no
2
+ # API token is stored anywhere. One-time setup: on pypi.org add a trusted publisher for this repository,
3
+ # workflow "publish.yml", environment "pypi".
4
+ name: publish
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ contents: read # a job-level permissions block drops the defaults; checkout of a private repo needs this
15
+ id-token: write # trusted publishing
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0 # the version is read from git tags
20
+ - uses: astral-sh/setup-uv@v5
21
+ - run: uv build
22
+ - name: Built version must match the release tag
23
+ run: |
24
+ set -euo pipefail
25
+ expected="${GITHUB_REF_NAME#v}"
26
+ built=$(ls dist/*.whl | sed -E 's#.*/[^-]+-([^-]+)-.*#\1#')
27
+ if [ "$built" != "$expected" ]; then
28
+ echo "::error::Built version $built does not match tag ${GITHUB_REF_NAME}"
29
+ exit 1
30
+ fi
31
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.10", "3.13"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v5
12
+ - run: uv python install ${{ matrix.python }}
13
+ - run: uv sync --python ${{ matrix.python }}
14
+ - run: uv run ruff check
15
+ - run: uv run pytest
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .env
10
+ *.pem
11
+ src/djangocloud_cli/_version.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter van Doorn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: djangocloud-cli
3
+ Version: 0.1.1
4
+ Summary: Deploy a Django app from the command line: djangocloud login, djangocloud deploy, djangocloud logs.
5
+ Project-URL: Homepage, https://djangocloud.dev
6
+ Project-URL: Source, https://github.com/two-trick-pony-NL/djangocloud-cli
7
+ Project-URL: Issues, https://github.com/two-trick-pony-NL/djangocloud-cli/issues
8
+ Author-email: Peter van Doorn <peter@petervandoorn.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: aws,cli,deploy,django,lightsail
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.0
17
+ Classifier: Framework :: Django :: 5.1
18
+ Classifier: Framework :: Django :: 5.2
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Topic :: Software Development
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: questionary>=2.0
29
+ Requires-Dist: rich>=13
30
+ Description-Content-Type: text/markdown
31
+
32
+ # djangocloud-cli
33
+
34
+ Command-line client for DjangoCloud. Standard library only.
35
+
36
+ ```
37
+ pip install djangocloud-cli # then add "djangocloud_cli" to INSTALLED_APPS
38
+ python manage.py djangocloud login
39
+ python manage.py djangocloud deploy
40
+ python manage.py djangocloud logs -f
41
+ python manage.py djangocloud help
42
+ ```
43
+
44
+ A standalone `djangocloud` script is installed too (same commands, no `manage.py`), for when your settings won't load.
45
+
46
+ **Status:** early. `help`, `login`, `logout`, `whoami`, `link`, `unlink` and the interactive setup in `deploy` work
47
+ (they need the DjangoCloud API). Uploading, building and deploying, `logs` and `status` are next.
48
+
49
+ ## First run
50
+
51
+ ```
52
+ $ python manage.py djangocloud deploy
53
+ You're not signed in yet.
54
+ Open https://djangocloud.dev/dashboard/cli/?code=ABCD-EFGH and check that the code is ABCD-EFGH.
55
+ ✓ Signed in as you@example.com
56
+ ? Which project is this? (arrow keys)
57
+ ? Server size Nano 0.25 vCPU, 0.5 GB RAM $10/month
58
+ ? This will cost $10 per month. Continue? Yes
59
+ ✓ Linked to my-shop (.djangocloud/config.json)
60
+ ```
61
+
62
+ `.djangocloud/config.json` says which project this folder deploys to. It holds no secrets and is git-ignored by a
63
+ `.gitignore` inside the folder. Later runs skip every prompt.
64
+
65
+ ## CI (GitHub Actions)
66
+
67
+ Create a token under **Command line → Token for CI** in the dashboard and store it as the repository secret
68
+ `DJANGOCLOUD_TOKEN`. `--no-input` makes the CLI fail with a clear message instead of ever prompting or opening a browser.
69
+
70
+ ```yaml
71
+ - run: pip install djangocloud-cli
72
+ - run: python manage.py djangocloud deploy --no-input --project my-shop
73
+ env:
74
+ DJANGOCLOUD_TOKEN: ${{ secrets.DJANGOCLOUD_TOKEN }}
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ | Variable | Purpose |
80
+ |---|---|
81
+ | `DJANGOCLOUD_TOKEN` | API token for CI; takes precedence over the stored login |
82
+ | `DJANGOCLOUD_NO_INPUT` | Same as `--no-input` (set it to `1`) |
83
+ | `DJANGOCLOUD_API` | API base URL (default `https://djangocloud.dev/api/v1`) |
84
+
85
+ The token from `login` is stored in `~/.config/djangocloud/credentials.json` (mode 600).
86
+
87
+ ## Development
88
+
89
+ ```
90
+ uv sync
91
+ uv run pytest
92
+ uv run ruff check
93
+ ```
@@ -0,0 +1,62 @@
1
+ # djangocloud-cli
2
+
3
+ Command-line client for DjangoCloud. Standard library only.
4
+
5
+ ```
6
+ pip install djangocloud-cli # then add "djangocloud_cli" to INSTALLED_APPS
7
+ python manage.py djangocloud login
8
+ python manage.py djangocloud deploy
9
+ python manage.py djangocloud logs -f
10
+ python manage.py djangocloud help
11
+ ```
12
+
13
+ A standalone `djangocloud` script is installed too (same commands, no `manage.py`), for when your settings won't load.
14
+
15
+ **Status:** early. `help`, `login`, `logout`, `whoami`, `link`, `unlink` and the interactive setup in `deploy` work
16
+ (they need the DjangoCloud API). Uploading, building and deploying, `logs` and `status` are next.
17
+
18
+ ## First run
19
+
20
+ ```
21
+ $ python manage.py djangocloud deploy
22
+ You're not signed in yet.
23
+ Open https://djangocloud.dev/dashboard/cli/?code=ABCD-EFGH and check that the code is ABCD-EFGH.
24
+ ✓ Signed in as you@example.com
25
+ ? Which project is this? (arrow keys)
26
+ ? Server size Nano 0.25 vCPU, 0.5 GB RAM $10/month
27
+ ? This will cost $10 per month. Continue? Yes
28
+ ✓ Linked to my-shop (.djangocloud/config.json)
29
+ ```
30
+
31
+ `.djangocloud/config.json` says which project this folder deploys to. It holds no secrets and is git-ignored by a
32
+ `.gitignore` inside the folder. Later runs skip every prompt.
33
+
34
+ ## CI (GitHub Actions)
35
+
36
+ Create a token under **Command line → Token for CI** in the dashboard and store it as the repository secret
37
+ `DJANGOCLOUD_TOKEN`. `--no-input` makes the CLI fail with a clear message instead of ever prompting or opening a browser.
38
+
39
+ ```yaml
40
+ - run: pip install djangocloud-cli
41
+ - run: python manage.py djangocloud deploy --no-input --project my-shop
42
+ env:
43
+ DJANGOCLOUD_TOKEN: ${{ secrets.DJANGOCLOUD_TOKEN }}
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ | Variable | Purpose |
49
+ |---|---|
50
+ | `DJANGOCLOUD_TOKEN` | API token for CI; takes precedence over the stored login |
51
+ | `DJANGOCLOUD_NO_INPUT` | Same as `--no-input` (set it to `1`) |
52
+ | `DJANGOCLOUD_API` | API base URL (default `https://djangocloud.dev/api/v1`) |
53
+
54
+ The token from `login` is stored in `~/.config/djangocloud/credentials.json` (mode 600).
55
+
56
+ ## Development
57
+
58
+ ```
59
+ uv sync
60
+ uv run pytest
61
+ uv run ruff check
62
+ ```
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "djangocloud-cli"
3
+ dynamic = ["version"] # from the git tag (hatch-vcs): tag v0.2.0 -> version 0.2.0
4
+ description = "Deploy a Django app from the command line: djangocloud login, djangocloud deploy, djangocloud logs."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = ["rich>=13", "questionary>=2.0"]
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ authors = [{ name = "Peter van Doorn", email = "peter@petervandoorn.com" }]
11
+ keywords = ["django", "deploy", "cli", "aws", "lightsail"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Framework :: Django",
16
+ "Framework :: Django :: 4.2",
17
+ "Framework :: Django :: 5.0",
18
+ "Framework :: Django :: 5.1",
19
+ "Framework :: Django :: 5.2",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://djangocloud.dev"
32
+ Source = "https://github.com/two-trick-pony-NL/djangocloud-cli"
33
+ Issues = "https://github.com/two-trick-pony-NL/djangocloud-cli/issues"
34
+
35
+ [project.scripts]
36
+ djangocloud = "djangocloud_cli.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling", "hatch-vcs"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.version]
43
+ source = "vcs"
44
+
45
+ [tool.hatch.build.hooks.vcs]
46
+ version-file = "src/djangocloud_cli/_version.py"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/djangocloud_cli"]
50
+
51
+ [dependency-groups]
52
+ dev = ["pytest>=8", "ruff>=0.6", "django>=4.2"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+
57
+ [tool.ruff]
58
+ line-length = 120
59
+ target-version = "py310"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "B", "UP"]
@@ -0,0 +1,6 @@
1
+ """Command-line client for DjangoCloud."""
2
+
3
+ try:
4
+ from ._version import __version__ # written at build time from the git tag
5
+ except ImportError: # running from a checkout that was never built
6
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,48 @@
1
+ """Minimal JSON client for the DjangoCloud API (urllib: no HTTP dependency to conflict with)."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+ from . import __version__
8
+
9
+ TIMEOUT = 30
10
+
11
+
12
+ class ApiError(Exception):
13
+ def __init__(self, status: int, code: str, message: str):
14
+ super().__init__(message)
15
+ self.status, self.code, self.message = status, code, message
16
+
17
+
18
+ class Client:
19
+ def __init__(self, base_url: str, token: str | None = None):
20
+ self.base_url = base_url.rstrip("/")
21
+ self.token = token
22
+
23
+ def request(self, method: str, path: str, data: dict | None = None) -> dict:
24
+ headers = {"Accept": "application/json", "User-Agent": f"djangocloud-cli/{__version__}"}
25
+ body = None
26
+ if data is not None:
27
+ body = json.dumps(data).encode()
28
+ headers["Content-Type"] = "application/json"
29
+ if self.token:
30
+ headers["Authorization"] = f"Bearer {self.token}"
31
+ req = urllib.request.Request(self.base_url + path, data=body, headers=headers, method=method)
32
+ try:
33
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
34
+ return json.loads(response.read() or b"{}")
35
+ except urllib.error.HTTPError as exc:
36
+ try:
37
+ payload = json.loads(exc.read())
38
+ except ValueError:
39
+ payload = {}
40
+ raise ApiError(exc.code, payload.get("error", "http_error"), payload.get("message", exc.reason)) from None
41
+ except (urllib.error.URLError, TimeoutError) as exc:
42
+ raise ApiError(0, "unreachable", f"Couldn't reach {self.base_url}: {getattr(exc, 'reason', exc)}") from None
43
+
44
+ def get(self, path: str) -> dict:
45
+ return self.request("GET", path)
46
+
47
+ def post(self, path: str, data: dict | None = None) -> dict:
48
+ return self.request("POST", path, data or {})
@@ -0,0 +1,37 @@
1
+ """`djangocloud login`: the device-code flow. No passwords or keys are typed into the terminal."""
2
+
3
+ import time
4
+ import webbrowser
5
+
6
+ from . import config
7
+ from .api import ApiError, Client
8
+ from .ui import console
9
+
10
+
11
+ def login(client: Client, *, sleep=time.sleep, open_browser=webbrowser.open, client_name: str = "cloud CLI") -> str:
12
+ started = client.post("/auth/device", {"client_name": client_name})
13
+ url, code = started["verification_uri_complete"], started["user_code"]
14
+ console.print(f"\nOpen [link={url}]{url}[/link]\nand check that the code is [bold]{code}[/bold].\n")
15
+ try:
16
+ open_browser(url)
17
+ except Exception: # noqa: BLE001 - no browser (SSH, CI) is fine: the URL is printed above
18
+ pass
19
+
20
+ interval = started.get("interval", 5)
21
+ deadline = time.monotonic() + started.get("expires_in", 600)
22
+ with console.status("Waiting for you to approve in the browser…"):
23
+ while time.monotonic() < deadline:
24
+ sleep(interval)
25
+ try:
26
+ token = client.post("/auth/token", {"device_code": started["device_code"]})["access_token"]
27
+ except ApiError as exc:
28
+ if exc.code == "authorization_pending":
29
+ continue
30
+ if exc.code == "slow_down":
31
+ interval += 5
32
+ continue
33
+ raise
34
+ config.save_token(token)
35
+ client.token = token
36
+ return token
37
+ raise ApiError(400, "expired_token", "That login expired. Run the login command again.")
@@ -0,0 +1,233 @@
1
+ """`djangocloud` command: one parser shared by the standalone script and `manage.py djangocloud`."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__, auth, config, link
7
+ from .api import ApiError, Client
8
+ from .ui import NotInteractive, confirm, console, err, interactive, no_input, select, set_no_input, text
9
+
10
+ STANDALONE = "djangocloud"
11
+ MANAGE_PY = "python manage.py djangocloud"
12
+ _prog = MANAGE_PY # what messages tell people to type; set by run() to match how we were invoked
13
+
14
+
15
+ def say(command: str) -> str:
16
+ """The command as the user types it, e.g. 'python manage.py djangocloud login'."""
17
+ return f"{_prog} {command}"
18
+
19
+
20
+ COMING_SOON = "'{command}' isn't available yet: the build and deploy pipeline is still being built."
21
+
22
+
23
+ def build_parser(prog: str = STANDALONE) -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog=prog, description="Deploy your Django app.")
25
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
26
+ parser.add_argument(
27
+ "--no-input",
28
+ action="store_true",
29
+ help="Never prompt or open a browser (for CI). Needs DJANGOCLOUD_TOKEN and a linked folder or --project",
30
+ )
31
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
32
+
33
+ sub.add_parser("login", help="Sign in (approve a code in your browser)")
34
+ sub.add_parser("logout", help="Forget the stored token")
35
+ sub.add_parser("whoami", help="Show who you are signed in as")
36
+
37
+ linkers = (
38
+ ("deploy", "Link this folder to a project, then deploy it"),
39
+ ("link", "Pick or create the project this folder deploys to"),
40
+ )
41
+ for name, helptext in linkers:
42
+ cmd = sub.add_parser(name, help=helptext)
43
+ cmd.add_argument("--project", help="Use this existing project (slug) without prompting")
44
+ cmd.add_argument("--name", help="Create a new project with this name without prompting")
45
+ cmd.add_argument("--size", help="Server size for a new project, e.g. nano")
46
+ cmd.add_argument("-y", "--yes", action="store_true", help="Don't ask for confirmation")
47
+ sub.add_parser("unlink", help="Detach this folder from its project")
48
+ sub.add_parser("status", help="Show the current release and its state")
49
+
50
+ logs = sub.add_parser("logs", help="Show a project's logs")
51
+ logs.add_argument("-f", "--follow", action="store_true", help="Keep streaming new lines")
52
+ logs.add_argument("--source", choices=["app", "build", "release"], help="Only this kind of log line")
53
+ logs.add_argument("--since", metavar="DURATION", help="Only lines newer than this, e.g. 2h")
54
+
55
+ helper = sub.add_parser("help", help="Show this help")
56
+ helper.add_argument("topic", nargs="?", help="A command to get help for")
57
+ return parser
58
+
59
+
60
+ class CliError(Exception):
61
+ pass
62
+
63
+
64
+ def make_client() -> Client:
65
+ return Client(config.api_url(), config.load_token())
66
+
67
+
68
+ def ensure_login(client: Client) -> None:
69
+ """Make sure the client holds a token the server accepts, signing in if it doesn't."""
70
+ if client.token:
71
+ try:
72
+ client.get("/me")
73
+ return
74
+ except ApiError as exc:
75
+ if exc.code != "unauthorized":
76
+ raise
77
+ if not interactive():
78
+ raise CliError(
79
+ "Not signed in. In CI set DJANGOCLOUD_TOKEN (create one at /dashboard/cli/); "
80
+ f"on your own machine run '{say('login')}'."
81
+ )
82
+ console.print("You're not signed in yet.")
83
+ auth.login(client)
84
+ console.print(f"[green]✓[/green] Signed in as {client.get('/me')['email']}")
85
+
86
+
87
+ def _price(cents: int) -> str:
88
+ return f"${cents / 100:,.0f}" if cents % 100 == 0 else f"${cents / 100:,.2f}"
89
+
90
+
91
+ def _size_label(size: dict) -> str:
92
+ specs = f"{size['vcpu']:g} vCPU, {size['ram_gb']:g} GB RAM"
93
+ return f"{size['label']:<8} {specs:<22} {_price(size['price_cents'])}/month"
94
+
95
+
96
+ def ensure_linked(client: Client, root, args, *, force: bool = False) -> dict:
97
+ existing = None if force else link.load(root)
98
+ if existing:
99
+ return existing
100
+
101
+ projects = client.get("/projects")["projects"]
102
+ if args.project:
103
+ match = next((p for p in projects if p["slug"] == args.project), None)
104
+ if match is None:
105
+ raise CliError(f"No project {args.project!r} on your account.")
106
+ project = match
107
+ else:
108
+ choice = args.name or select(
109
+ "Which project is this?",
110
+ [("Create a new project", None)] + [(f"{p['name'] or p['slug']} ({p['slug']})", p) for p in projects],
111
+ )
112
+ project = choice if isinstance(choice, dict) else create_project(client, root, args)
113
+ path = link.save(root, project, config.api_url())
114
+ console.print(f"[green]✓[/green] Linked to [bold]{project['slug']}[/bold] ({path.relative_to(root)})")
115
+ return project
116
+
117
+
118
+ def create_project(client: Client, root, args) -> dict:
119
+ name = args.name or text("Project name", default=root.name)
120
+ sizes = client.get("/sizes")["sizes"]
121
+ if args.size:
122
+ size = next((s for s in sizes if s["power"] == args.size), None)
123
+ if size is None:
124
+ raise CliError(f"Unknown size {args.size!r}. Choose from: {', '.join(s['power'] for s in sizes)}.")
125
+ else:
126
+ size = select("Server size", [(_size_label(s), s) for s in sizes])
127
+ monthly = _price(size["price_cents"])
128
+ if not (args.yes or no_input()) and not confirm(f"This will cost {monthly} per month. Continue?"):
129
+ raise CliError("Cancelled. Nothing was created.")
130
+ try:
131
+ return client.post("/projects", {"name": name, "power": size["power"]})
132
+ except ApiError as exc:
133
+ if exc.code == "payment_required":
134
+ raise CliError(f"{exc.message}") from None
135
+ raise
136
+
137
+
138
+ def cmd_login(args, root) -> int:
139
+ if not interactive():
140
+ raise CliError(f"'{say('login')}' needs a browser. In CI use a DJANGOCLOUD_TOKEN instead.")
141
+ client = make_client()
142
+ auth.login(client)
143
+ console.print(f"[green]✓[/green] Signed in as {client.get('/me')['email']}")
144
+ return 0
145
+
146
+
147
+ def cmd_whoami(args, root) -> int:
148
+ client = make_client()
149
+ if not client.token:
150
+ console.print(f"Not signed in. Run [bold]{say('login')}[/bold].")
151
+ return 1
152
+ try:
153
+ me = client.get("/me")
154
+ except ApiError as exc:
155
+ if exc.code != "unauthorized":
156
+ raise
157
+ console.print(f"Your token is no longer valid. Run [bold]{say('login')}[/bold].")
158
+ return 1
159
+ console.print(f"Signed in as [bold]{me['email']}[/bold]")
160
+ linked = link.load(root)
161
+ if linked:
162
+ console.print(f"This folder deploys to [bold]{linked['slug']}[/bold].")
163
+ return 0
164
+
165
+
166
+ def cmd_logout(args, root) -> int:
167
+ console.print("Signed out." if config.clear_token() else "You weren't signed in.")
168
+ return 0
169
+
170
+
171
+ def cmd_link(args, root) -> int:
172
+ client = make_client()
173
+ ensure_login(client)
174
+ ensure_linked(client, root, args, force=True)
175
+ return 0
176
+
177
+
178
+ def cmd_unlink(args, root) -> int:
179
+ console.print("Unlinked." if link.remove(root) else "This folder isn't linked.")
180
+ return 0
181
+
182
+
183
+ def cmd_deploy(args, root) -> int:
184
+ client = make_client()
185
+ ensure_login(client)
186
+ project = ensure_linked(client, root, args)
187
+ console.print(f"Ready to deploy [bold]{project['slug']}[/bold].")
188
+ err.print(COMING_SOON.format(command=say("deploy")))
189
+ err.print("Your project is linked, so the next release will pick it up.")
190
+ return 2
191
+
192
+
193
+ COMMANDS = {
194
+ "login": cmd_login,
195
+ "logout": cmd_logout,
196
+ "whoami": cmd_whoami,
197
+ "link": cmd_link,
198
+ "unlink": cmd_unlink,
199
+ "deploy": cmd_deploy,
200
+ }
201
+
202
+
203
+ def run(argv: list[str] | None = None, prog: str = STANDALONE) -> int:
204
+ global _prog
205
+ _prog = prog
206
+ parser = build_parser(prog)
207
+ args = parser.parse_args(argv)
208
+ set_no_input(getattr(args, "no_input", False))
209
+
210
+ if args.command in (None, "help"):
211
+ topic = getattr(args, "topic", None)
212
+ if topic:
213
+ return run([topic, "--help"], prog) # argparse prints the sub-command's help, then exits
214
+ parser.print_help()
215
+ return 0
216
+ handler = COMMANDS.get(args.command)
217
+ if handler is None:
218
+ err.print(COMING_SOON.format(command=say(args.command)))
219
+ return 2
220
+ try:
221
+ return handler(args, link.find_root())
222
+ except (CliError, NotInteractive) as exc:
223
+ err.print(f"[red]Error:[/red] {exc}")
224
+ except ApiError as exc:
225
+ err.print(f"[red]Error:[/red] {exc.message}")
226
+ except KeyboardInterrupt:
227
+ err.print("\nCancelled.")
228
+ return 130
229
+ return 1
230
+
231
+
232
+ def main() -> None:
233
+ sys.exit(run(sys.argv[1:]))