djangocloud-cli 0.1.1__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.
@@ -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
djangocloud_cli/api.py ADDED
@@ -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.")
djangocloud_cli/cli.py ADDED
@@ -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:]))
@@ -0,0 +1,52 @@
1
+ """Where the CLI keeps its API token, and how it finds one.
2
+
3
+ Order: the DJANGOCLOUD_TOKEN environment variable (CI), then the credentials file written by
4
+ `djangocloud login`. The token is a bearer secret, so the file is created readable by its owner only.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import stat
10
+ from pathlib import Path
11
+
12
+ TOKEN_ENV = "DJANGOCLOUD_TOKEN"
13
+ API_ENV = "DJANGOCLOUD_API"
14
+ DEFAULT_API = "https://djangocloud.dev/api/v1"
15
+
16
+
17
+ def credentials_path() -> Path:
18
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
19
+ return base / "djangocloud" / "credentials.json"
20
+
21
+
22
+ def api_url() -> str:
23
+ return os.environ.get(API_ENV, DEFAULT_API).rstrip("/")
24
+
25
+
26
+ def load_token() -> str | None:
27
+ token = os.environ.get(TOKEN_ENV)
28
+ if token:
29
+ return token
30
+ try:
31
+ return json.loads(credentials_path().read_text()).get("token")
32
+ except (OSError, ValueError):
33
+ return None
34
+
35
+
36
+ def save_token(token: str) -> Path:
37
+ path = credentials_path()
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ # Create with 0600 from the start so the token is never briefly world-readable.
40
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
41
+ with os.fdopen(fd, "w") as handle:
42
+ json.dump({"token": token}, handle)
43
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
44
+ return path
45
+
46
+
47
+ def clear_token() -> bool:
48
+ try:
49
+ credentials_path().unlink()
50
+ except FileNotFoundError:
51
+ return False
52
+ return True
@@ -0,0 +1,51 @@
1
+ """The link between a folder and a DjangoCloud project: `.djangocloud/config.json`.
2
+
3
+ Written by the CLI, holds no secrets, and is git-ignored by a `.gitignore` inside the folder, so it
4
+ can't be committed by accident. The API token lives in the user's config directory, not here.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ DIR = ".djangocloud"
11
+ FILE = "config.json"
12
+
13
+
14
+ def find_root(start: Path | None = None) -> Path:
15
+ """The nearest folder already linked, else the one holding manage.py, else where we are."""
16
+ start = (start or Path.cwd()).resolve()
17
+ for folder in (start, *start.parents):
18
+ if (folder / DIR / FILE).is_file():
19
+ return folder
20
+ for folder in (start, *start.parents):
21
+ if (folder / "manage.py").is_file():
22
+ return folder
23
+ return start
24
+
25
+
26
+ def load(root: Path) -> dict | None:
27
+ try:
28
+ data = json.loads((root / DIR / FILE).read_text())
29
+ except (OSError, ValueError):
30
+ return None
31
+ if not isinstance(data, dict) or not data.get("project"):
32
+ return None
33
+ # Same shape the API returns for a project, so callers treat both alike.
34
+ return {"id": data.get("project_id"), "slug": data["project"], "api": data.get("api", "")}
35
+
36
+
37
+ def save(root: Path, project: dict, api: str) -> Path:
38
+ folder = root / DIR
39
+ folder.mkdir(exist_ok=True)
40
+ (folder / ".gitignore").write_text("*\n")
41
+ path = folder / FILE
42
+ path.write_text(json.dumps({"project_id": project["id"], "project": project["slug"], "api": api}, indent=2) + "\n")
43
+ return path
44
+
45
+
46
+ def remove(root: Path) -> bool:
47
+ path = root / DIR / FILE
48
+ if not path.exists():
49
+ return False
50
+ path.unlink()
51
+ return True
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ """`python manage.py djangocloud <command>`: the same CLI, run inside the user's Django project."""
2
+
3
+ from django.core.management.base import BaseCommand
4
+
5
+ from djangocloud_cli.cli import MANAGE_PY, run
6
+
7
+
8
+ class Command(BaseCommand):
9
+ help = "Deploy this project: login | deploy | logs | status | help (run with no arguments for details)"
10
+
11
+ def add_arguments(self, parser):
12
+ parser.add_argument("args", nargs="*") # handed to the shared parser untouched
13
+
14
+ def run_from_argv(self, argv):
15
+ # Skip Django's own option parsing so flags like -f and --since reach our parser.
16
+ raise SystemExit(run(argv[2:], prog=MANAGE_PY))
djangocloud_cli/ui.py ADDED
@@ -0,0 +1,64 @@
1
+ """Terminal output and prompts. Prompts need a terminal: scripts must pass flags instead."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import questionary
7
+ from rich.console import Console
8
+
9
+ console = Console()
10
+ err = Console(stderr=True)
11
+
12
+
13
+ class NotInteractive(Exception):
14
+ pass
15
+
16
+
17
+ NO_INPUT_ENV = "DJANGOCLOUD_NO_INPUT"
18
+ _no_input = False
19
+
20
+
21
+ def set_no_input(value: bool) -> None:
22
+ """--no-input (or DJANGOCLOUD_NO_INPUT=1): never prompt, never open a browser. For CI."""
23
+ global _no_input
24
+ _no_input = value
25
+
26
+
27
+ def no_input() -> bool:
28
+ return _no_input or os.environ.get(NO_INPUT_ENV, "") not in ("", "0", "false")
29
+
30
+
31
+ def interactive() -> bool:
32
+ return not no_input() and sys.stdin.isatty() and sys.stdout.isatty()
33
+
34
+
35
+ def _need_terminal(what: str) -> None:
36
+ if not interactive():
37
+ raise NotInteractive(
38
+ f"Can't ask for {what} without a terminal (or with --no-input). "
39
+ "Pass it as a flag, e.g. --project, --name, --size, --yes."
40
+ )
41
+
42
+
43
+ def ask(question: questionary.Question):
44
+ answer = question.ask()
45
+ if answer is None: # Ctrl-C inside a prompt
46
+ raise KeyboardInterrupt
47
+ return answer
48
+
49
+
50
+ def text(message: str, default: str = "") -> str:
51
+ _need_terminal(message.lower())
52
+ return ask(questionary.text(message, default=default))
53
+
54
+
55
+ def confirm(message: str, default: bool = True) -> bool:
56
+ _need_terminal("confirmation")
57
+ return ask(questionary.confirm(message, default=default))
58
+
59
+
60
+ def select(message: str, choices: list[tuple[str, object]]):
61
+ """Arrow-key menu over (label, value) pairs; returns the chosen value."""
62
+ _need_terminal(message.lower())
63
+ options = [questionary.Choice(title=label, value=value) for label, value in choices]
64
+ return ask(questionary.select(message, choices=options))
@@ -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,16 @@
1
+ djangocloud_cli/__init__.py,sha256=bW6IVL54sMH5jro61y1lLpJZNNwBUFqwhdIrFioxljA,231
2
+ djangocloud_cli/_version.py,sha256=mEr1FdwwZVMovyM6vpPg2W8zwa1u1f4KdymTrLcqf9U,520
3
+ djangocloud_cli/api.py,sha256=Bp2yUANAD-ZdUniGsJQbdAlM1njOwnxBKhn7m9Pplvg,1878
4
+ djangocloud_cli/auth.py,sha256=SyV-7vyO_5luj4WNCMpDY2LDDT82QSK5CykybJ3S7Qw,1539
5
+ djangocloud_cli/cli.py,sha256=bAKWeq6xkFZcRoig2VQCP3CP0y1Nj9DKUK9aEoHbJuE,8557
6
+ djangocloud_cli/config.py,sha256=olHIM0qbzEkcJ2mUrNXNBwbt6sgpNgGVBemkzWiP8Hg,1500
7
+ djangocloud_cli/link.py,sha256=rMrly2_U5vWKBNCQcshEIIuYvtowwoZQPiJ_Yj-QA_Y,1679
8
+ djangocloud_cli/ui.py,sha256=Jg1u9MpcICAJAUTl7WdAWnjku-bI4PjrzEOsfvVn3jc,1764
9
+ djangocloud_cli/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ djangocloud_cli/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ djangocloud_cli/management/commands/djangocloud.py,sha256=qPvns74-mb-Tf1TfsPF2r_r78hFKkCc55EhPY5wOE1Y,647
12
+ djangocloud_cli-0.1.1.dist-info/METADATA,sha256=9ABQNhVs1PoDLOmgUUNSE1kajlfySHiKRZOVcCnhMoQ,3394
13
+ djangocloud_cli-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ djangocloud_cli-0.1.1.dist-info/entry_points.txt,sha256=2SmBlcQIHSQxVJoqn9dFN0MeCn4y2m252MjYsCNM8Xc,57
15
+ djangocloud_cli-0.1.1.dist-info/licenses/LICENSE,sha256=9JBaP5jj-a_qIFMHTXkCD-6dpP2VmwUZpNgWHLS9Kiw,1072
16
+ djangocloud_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ djangocloud = djangocloud_cli.cli:main
@@ -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.