doploy-cli 0.2.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.
- doploy_cli-0.2.0/LICENSE +21 -0
- doploy_cli-0.2.0/PKG-INFO +63 -0
- doploy_cli-0.2.0/README.md +41 -0
- doploy_cli-0.2.0/doploy_cli/__init__.py +0 -0
- doploy_cli-0.2.0/doploy_cli/main.py +553 -0
- doploy_cli-0.2.0/doploy_cli.egg-info/PKG-INFO +63 -0
- doploy_cli-0.2.0/doploy_cli.egg-info/SOURCES.txt +11 -0
- doploy_cli-0.2.0/doploy_cli.egg-info/dependency_links.txt +1 -0
- doploy_cli-0.2.0/doploy_cli.egg-info/entry_points.txt +2 -0
- doploy_cli-0.2.0/doploy_cli.egg-info/requires.txt +1 -0
- doploy_cli-0.2.0/doploy_cli.egg-info/top_level.txt +1 -0
- doploy_cli-0.2.0/pyproject.toml +33 -0
- doploy_cli-0.2.0/setup.cfg +4 -0
doploy_cli-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 doploy
|
|
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,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: doploy-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal or CI
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://doploy.io
|
|
7
|
+
Project-URL: Documentation, https://docs.doploy.io
|
|
8
|
+
Project-URL: Dashboard, https://cloud.doploy.io
|
|
9
|
+
Keywords: doploy,odoo,deploy,ci,cli
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# doploy CLI
|
|
24
|
+
|
|
25
|
+
Watch builds, read logs, and drive your Odoo deployments on [doploy](https://doploy.io)
|
|
26
|
+
from a terminal or a CI pipeline.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install doploy-cli
|
|
30
|
+
doploy login # opens your browser; no tokens to paste
|
|
31
|
+
doploy projects
|
|
32
|
+
doploy builds <project-id> --type production
|
|
33
|
+
doploy logs <build-id> --source test -f
|
|
34
|
+
doploy update-modules <build-id> --wait
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Auth
|
|
38
|
+
|
|
39
|
+
`doploy login` signs you in with your doploy account (OAuth, PKCE, loopback
|
|
40
|
+
callback). Tokens refresh themselves and live in `~/.config/doploy/config.json`
|
|
41
|
+
(mode 0600). Whether the CLI may deploy is controlled per app in
|
|
42
|
+
Settings > Connected apps.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `projects` | List your projects |
|
|
50
|
+
| `builds <project>` | Recent builds, filter with `--type` |
|
|
51
|
+
| `build <id>` | One build in full |
|
|
52
|
+
| `tests <build>` | Test failures, `--details` for tracebacks |
|
|
53
|
+
| `logs <build>` | Tail logs; `--source build\|test\|odoo`, `-f` to follow |
|
|
54
|
+
| `branch create <project> <name>` | Fork a dev branch and queue its build |
|
|
55
|
+
| `rebuild <build>` | Queue a rebuild |
|
|
56
|
+
| `restart <build>` | Restart the Odoo service |
|
|
57
|
+
| `update-modules <build> [modules]` | Run `odoo-bin -u`; `--theme`, `--wait` |
|
|
58
|
+
| `backup <build>` | Start a backup; `--wait` |
|
|
59
|
+
| `task <id>` | Poll a queued task |
|
|
60
|
+
| `logout` | Forget saved credentials |
|
|
61
|
+
|
|
62
|
+
Production targets always need `--confirm`. Destructive operations
|
|
63
|
+
(deletes, restores) are not exposed.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# doploy CLI
|
|
2
|
+
|
|
3
|
+
Watch builds, read logs, and drive your Odoo deployments on [doploy](https://doploy.io)
|
|
4
|
+
from a terminal or a CI pipeline.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pipx install doploy-cli
|
|
8
|
+
doploy login # opens your browser; no tokens to paste
|
|
9
|
+
doploy projects
|
|
10
|
+
doploy builds <project-id> --type production
|
|
11
|
+
doploy logs <build-id> --source test -f
|
|
12
|
+
doploy update-modules <build-id> --wait
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Auth
|
|
16
|
+
|
|
17
|
+
`doploy login` signs you in with your doploy account (OAuth, PKCE, loopback
|
|
18
|
+
callback). Tokens refresh themselves and live in `~/.config/doploy/config.json`
|
|
19
|
+
(mode 0600). Whether the CLI may deploy is controlled per app in
|
|
20
|
+
Settings > Connected apps.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
| Command | What it does |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `projects` | List your projects |
|
|
28
|
+
| `builds <project>` | Recent builds, filter with `--type` |
|
|
29
|
+
| `build <id>` | One build in full |
|
|
30
|
+
| `tests <build>` | Test failures, `--details` for tracebacks |
|
|
31
|
+
| `logs <build>` | Tail logs; `--source build\|test\|odoo`, `-f` to follow |
|
|
32
|
+
| `branch create <project> <name>` | Fork a dev branch and queue its build |
|
|
33
|
+
| `rebuild <build>` | Queue a rebuild |
|
|
34
|
+
| `restart <build>` | Restart the Odoo service |
|
|
35
|
+
| `update-modules <build> [modules]` | Run `odoo-bin -u`; `--theme`, `--wait` |
|
|
36
|
+
| `backup <build>` | Start a backup; `--wait` |
|
|
37
|
+
| `task <id>` | Poll a queued task |
|
|
38
|
+
| `logout` | Forget saved credentials |
|
|
39
|
+
|
|
40
|
+
Production targets always need `--confirm`. Destructive operations
|
|
41
|
+
(deletes, restores) are not exposed.
|
|
File without changes
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""doploy CLI: thin client over the platform API (/api/platform/v1).
|
|
2
|
+
|
|
3
|
+
Auth: `doploy login` opens the browser and signs you in with your doploy
|
|
4
|
+
account (OAuth, PKCE, loopback callback); tokens refresh themselves. Config
|
|
5
|
+
lives at ~/.config/doploy/config.json. DOPLOY_TOKEN / --token accept a
|
|
6
|
+
platform API token for unattended use; deliberately undocumented.
|
|
7
|
+
|
|
8
|
+
Same API surface the MCP tools use, so agent/human/CI parity is automatic.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import http.server
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
import webbrowser
|
|
20
|
+
import stat
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE = 'https://cloud.doploy.io'
|
|
30
|
+
CONFIG_PATH = Path(os.environ.get(
|
|
31
|
+
'DOPLOY_CONFIG', Path.home() / '.config' / 'doploy' / 'config.json'))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_config() -> dict:
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
37
|
+
except Exception:
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_config(cfg: dict) -> None:
|
|
42
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + '\n')
|
|
44
|
+
CONFIG_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600: token inside
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Client:
|
|
48
|
+
def __init__(self, base: str, token: str, refresh: Optional[dict] = None):
|
|
49
|
+
self.base = base.rstrip('/')
|
|
50
|
+
self.token = token
|
|
51
|
+
# OAuth session (from `doploy login`): {'refresh_token', 'client_id',
|
|
52
|
+
# 'expires_at'}. When set, an expired/rejected access token is
|
|
53
|
+
# refreshed once and the new pair is saved to the config file.
|
|
54
|
+
self.refresh = refresh
|
|
55
|
+
|
|
56
|
+
def _refresh_access_token(self) -> bool:
|
|
57
|
+
if not self.refresh or not self.refresh.get('refresh_token'):
|
|
58
|
+
return False
|
|
59
|
+
r = _oauth_token_request(self.base, {
|
|
60
|
+
'grant_type': 'refresh_token',
|
|
61
|
+
'refresh_token': self.refresh['refresh_token'],
|
|
62
|
+
'client_id': self.refresh['client_id'],
|
|
63
|
+
})
|
|
64
|
+
if r is None:
|
|
65
|
+
return False
|
|
66
|
+
self.token = r['access_token']
|
|
67
|
+
self.refresh['refresh_token'] = r['refresh_token']
|
|
68
|
+
self.refresh['expires_at'] = time.time() + int(r.get('expires_in') or 3600)
|
|
69
|
+
cfg = _load_config()
|
|
70
|
+
cfg.update(access_token=self.token, refresh_token=self.refresh['refresh_token'],
|
|
71
|
+
expires_at=self.refresh['expires_at'])
|
|
72
|
+
_save_config(cfg)
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def request(self, method: str, path: str, **kw):
|
|
76
|
+
if self.refresh and self.refresh.get('expires_at', 0) < time.time() + 30:
|
|
77
|
+
self._refresh_access_token()
|
|
78
|
+
r = self._send(method, path, **kw)
|
|
79
|
+
if r.status_code == 401 and self._refresh_access_token():
|
|
80
|
+
r = self._send(method, path, **kw)
|
|
81
|
+
if r.status_code >= 400:
|
|
82
|
+
try:
|
|
83
|
+
detail = r.json().get('error', r.text)
|
|
84
|
+
except Exception:
|
|
85
|
+
detail = r.text[:300]
|
|
86
|
+
if r.status_code == 401:
|
|
87
|
+
detail += ' (run `doploy login`)'
|
|
88
|
+
_fail(f'{r.status_code}: {detail}')
|
|
89
|
+
return r.json()
|
|
90
|
+
|
|
91
|
+
def _send(self, method: str, path: str, **kw):
|
|
92
|
+
url = f'{self.base}/api/platform/v1{path}'
|
|
93
|
+
headers = {'Authorization': f'Bearer {self.token}'}
|
|
94
|
+
with httpx.Client(timeout=60.0, follow_redirects=True) as c:
|
|
95
|
+
return c.request(method, url, headers=headers, **kw)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _oauth_token_request(base: str, data: dict) -> Optional[dict]:
|
|
99
|
+
with httpx.Client(timeout=30.0, follow_redirects=True) as c:
|
|
100
|
+
r = c.post(f'{base.rstrip("/")}/oauth/mcp/token', data=data)
|
|
101
|
+
if r.status_code >= 400:
|
|
102
|
+
return None
|
|
103
|
+
return r.json()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fail(msg: str) -> None:
|
|
107
|
+
print(f'error: {msg}', file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _client(args) -> Client:
|
|
112
|
+
cfg = _load_config()
|
|
113
|
+
base = args.base or os.environ.get('DOPLOY_BASE') or cfg.get('base') or DEFAULT_BASE
|
|
114
|
+
# Precedence: --token, DOPLOY_TOKEN (CI), saved PAT, saved OAuth session.
|
|
115
|
+
token = args.token or os.environ.get('DOPLOY_TOKEN') or cfg.get('token')
|
|
116
|
+
if token:
|
|
117
|
+
return Client(base, token)
|
|
118
|
+
if cfg.get('refresh_token'):
|
|
119
|
+
return Client(base, cfg.get('access_token') or '', refresh={
|
|
120
|
+
'refresh_token': cfg['refresh_token'],
|
|
121
|
+
'client_id': cfg.get('client_id'),
|
|
122
|
+
'expires_at': cfg.get('expires_at', 0),
|
|
123
|
+
})
|
|
124
|
+
_fail('not logged in; run `doploy login`')
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _table(rows: list[dict], columns: list[str]) -> None:
|
|
128
|
+
if not rows:
|
|
129
|
+
print('(none)')
|
|
130
|
+
return
|
|
131
|
+
widths = {c: max(len(c), *(len(str(r.get(c, ''))) for r in rows)) for c in columns}
|
|
132
|
+
print(' '.join(c.upper().ljust(widths[c]) for c in columns))
|
|
133
|
+
for r in rows:
|
|
134
|
+
print(' '.join(str(r.get(c, '') if r.get(c) is not None else '')
|
|
135
|
+
.ljust(widths[c]) for c in columns))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --- commands ---------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _free_port() -> int:
|
|
141
|
+
with socket.socket() as s:
|
|
142
|
+
s.bind(('127.0.0.1', 0))
|
|
143
|
+
return s.getsockname()[1]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _wait_for_callback(port: int, state: str, timeout: float = 300.0) -> str:
|
|
147
|
+
"""Serve one loopback request and return the OAuth `code` it carried."""
|
|
148
|
+
from urllib.parse import parse_qs, urlparse
|
|
149
|
+
result: dict = {}
|
|
150
|
+
|
|
151
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
152
|
+
def do_GET(self): # noqa: N802
|
|
153
|
+
q = parse_qs(urlparse(self.path).query)
|
|
154
|
+
if q.get('state', [''])[0] != state:
|
|
155
|
+
body, result['error'] = 'State mismatch. Close this tab and retry.', 'state mismatch'
|
|
156
|
+
elif q.get('error'):
|
|
157
|
+
body, result['error'] = 'Login cancelled. You can close this tab.', q['error'][0]
|
|
158
|
+
else:
|
|
159
|
+
body, result['code'] = 'Logged in to doploy. You can close this tab.', q.get('code', [''])[0]
|
|
160
|
+
self.send_response(200)
|
|
161
|
+
self.send_header('Content-Type', 'text/html')
|
|
162
|
+
self.end_headers()
|
|
163
|
+
self.wfile.write(f'<html><body style="font-family:sans-serif"><p>{body}</p></body></html>'.encode())
|
|
164
|
+
|
|
165
|
+
def log_message(self, *a): # silence
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
server = http.server.HTTPServer(('127.0.0.1', port), Handler)
|
|
169
|
+
server.timeout = timeout
|
|
170
|
+
t = threading.Thread(target=server.handle_request, daemon=True)
|
|
171
|
+
t.start()
|
|
172
|
+
t.join(timeout)
|
|
173
|
+
server.server_close()
|
|
174
|
+
if result.get('error'):
|
|
175
|
+
_fail(f'login failed: {result["error"]}')
|
|
176
|
+
if not result.get('code'):
|
|
177
|
+
_fail('login timed out waiting for the browser')
|
|
178
|
+
return result['code']
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _browser_login(base: str) -> None:
|
|
182
|
+
issuer = f'{base.rstrip("/")}/oauth/mcp'
|
|
183
|
+
port = _free_port()
|
|
184
|
+
redirect_uri = f'http://127.0.0.1:{port}/callback'
|
|
185
|
+
with httpx.Client(timeout=30.0, follow_redirects=True) as c:
|
|
186
|
+
r = c.post(f'{issuer}/register', json={
|
|
187
|
+
'client_name': 'doploy CLI', 'redirect_uris': [redirect_uri]})
|
|
188
|
+
if r.status_code >= 400:
|
|
189
|
+
_fail(f'could not register with {issuer}: {r.text[:200]}')
|
|
190
|
+
client_id = r.json()['client_id']
|
|
191
|
+
|
|
192
|
+
verifier = secrets.token_urlsafe(48)
|
|
193
|
+
challenge = base64.urlsafe_b64encode(
|
|
194
|
+
hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')
|
|
195
|
+
state = secrets.token_urlsafe(16)
|
|
196
|
+
from urllib.parse import urlencode
|
|
197
|
+
url = f'{issuer}/authorize?' + urlencode({
|
|
198
|
+
'response_type': 'code', 'client_id': client_id, 'redirect_uri': redirect_uri,
|
|
199
|
+
'state': state, 'scope': 'doploy.platform',
|
|
200
|
+
'code_challenge': challenge, 'code_challenge_method': 'S256'})
|
|
201
|
+
print('Opening your browser to sign in to doploy. If it does not open, visit:')
|
|
202
|
+
print(f' {url}')
|
|
203
|
+
webbrowser.open(url)
|
|
204
|
+
code = _wait_for_callback(port, state)
|
|
205
|
+
|
|
206
|
+
tok = _oauth_token_request(base, {
|
|
207
|
+
'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirect_uri,
|
|
208
|
+
'client_id': client_id, 'code_verifier': verifier})
|
|
209
|
+
if tok is None:
|
|
210
|
+
_fail('token exchange failed')
|
|
211
|
+
cfg = _load_config()
|
|
212
|
+
cfg.pop('token', None) # an OAuth login replaces any saved PAT
|
|
213
|
+
cfg.update(base=base, client_id=client_id, access_token=tok['access_token'],
|
|
214
|
+
refresh_token=tok['refresh_token'],
|
|
215
|
+
expires_at=time.time() + int(tok.get('expires_in') or 3600))
|
|
216
|
+
_save_config(cfg)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_login(args):
|
|
220
|
+
base = args.base or os.environ.get('DOPLOY_BASE') or DEFAULT_BASE
|
|
221
|
+
if args.token_value or args.with_token:
|
|
222
|
+
token = args.token_value or input('Platform API token (dppat_...): ').strip()
|
|
223
|
+
if not token.startswith('dppat_'):
|
|
224
|
+
_fail("that doesn't look like a doploy platform token (dppat_...)")
|
|
225
|
+
Client(base, token).request('GET', '/projects') # validate before saving
|
|
226
|
+
cfg = _load_config()
|
|
227
|
+
for k in ('access_token', 'refresh_token', 'client_id', 'expires_at'):
|
|
228
|
+
cfg.pop(k, None)
|
|
229
|
+
cfg.update(token=token, base=base)
|
|
230
|
+
_save_config(cfg)
|
|
231
|
+
else:
|
|
232
|
+
_browser_login(base)
|
|
233
|
+
projects = _client(argparse.Namespace(token=None, base=base)).request('GET', '/projects')
|
|
234
|
+
print(f'logged in; {len(projects["projects"])} project(s) visible; config saved to {CONFIG_PATH}')
|
|
235
|
+
print('Deploy actions: toggle "Allow deploy actions" in Settings > MCP Connections.')
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cmd_logout(args):
|
|
239
|
+
cfg = _load_config()
|
|
240
|
+
for k in ('token', 'access_token', 'refresh_token', 'client_id', 'expires_at'):
|
|
241
|
+
cfg.pop(k, None)
|
|
242
|
+
_save_config(cfg)
|
|
243
|
+
print('logged out (revoke the grant in Settings > MCP Connections to kill the session server-side)')
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_projects(args):
|
|
247
|
+
data = _client(args).request('GET', '/projects')
|
|
248
|
+
if args.json:
|
|
249
|
+
print(json.dumps(data, indent=2))
|
|
250
|
+
return
|
|
251
|
+
_table(data['projects'], ['id', 'name', 'odoo_version'])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_builds(args):
|
|
255
|
+
params = {'limit': str(args.limit)}
|
|
256
|
+
if args.type:
|
|
257
|
+
params['type'] = args.type
|
|
258
|
+
data = _client(args).request('GET', f'/projects/{args.project}/builds', params=params)
|
|
259
|
+
if args.json:
|
|
260
|
+
print(json.dumps(data, indent=2))
|
|
261
|
+
return
|
|
262
|
+
_table(data['builds'], ['id', 'type', 'status', 'fqdn', 'commit_sha', 'created'])
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_build(args):
|
|
266
|
+
data = _client(args).request('GET', f'/builds/{args.build_id}')
|
|
267
|
+
print(json.dumps(data['build'], indent=2))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_tests(args):
|
|
271
|
+
data = _client(args).request('GET', f'/builds/{args.build_id}/test-results')
|
|
272
|
+
if args.json:
|
|
273
|
+
print(json.dumps(data, indent=2))
|
|
274
|
+
return
|
|
275
|
+
c = data['counts']
|
|
276
|
+
print(f"build {data['build_id']} [{data['build_status']}] — "
|
|
277
|
+
f"{c['error']} errors, {c['fail']} failures, {c['warning']} warnings")
|
|
278
|
+
for r in data['results']:
|
|
279
|
+
if r['error_type'] == 'WARNING' and not args.warnings:
|
|
280
|
+
continue
|
|
281
|
+
print(f"\n[{r['error_type']}] {r['module']}.{r['test']}")
|
|
282
|
+
if args.details:
|
|
283
|
+
print(r['details'])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cmd_rebuild(args):
|
|
287
|
+
data = _client(args).request(
|
|
288
|
+
'POST', f'/builds/{args.build_id}/rebuild',
|
|
289
|
+
json={'confirm': args.confirm})
|
|
290
|
+
print(f"rebuild queued: new build {data['new_build_id']} ({data['fqdn']})")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_restart(args):
|
|
294
|
+
data = _client(args).request(
|
|
295
|
+
'POST', f'/builds/{args.build_id}/restart',
|
|
296
|
+
json={'confirm': args.confirm})
|
|
297
|
+
print(data.get('message', 'restart queued'))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _new_lines(previous: list[str], current: list[str]) -> list[str]:
|
|
301
|
+
"""Lines in `current` not yet printed, given the last tail `previous`.
|
|
302
|
+
|
|
303
|
+
Both are tails of the same growing log, so the longest suffix of
|
|
304
|
+
`previous` that is a prefix of `current` is the overlap; everything after
|
|
305
|
+
it is new. An empty overlap means the log rotated or grew past the window,
|
|
306
|
+
in which case the whole window is new.
|
|
307
|
+
"""
|
|
308
|
+
if not previous:
|
|
309
|
+
return current
|
|
310
|
+
max_overlap = min(len(previous), len(current))
|
|
311
|
+
for size in range(max_overlap, 0, -1):
|
|
312
|
+
if previous[-size:] == current[:size]:
|
|
313
|
+
return current[size:]
|
|
314
|
+
return current
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
_TERMINAL_BUILD_STATUSES = {'success', 'error', 'warning', 'active error', 'cancelled', 'deleted'}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cmd_logs(args):
|
|
321
|
+
client = _client(args)
|
|
322
|
+
params = {'source': args.source, 'lines': str(args.lines)}
|
|
323
|
+
if args.filter:
|
|
324
|
+
params['filter'] = args.filter
|
|
325
|
+
data = client.request('GET', f'/builds/{args.build_id}/logs', params=params)
|
|
326
|
+
if args.json:
|
|
327
|
+
print(json.dumps(data, indent=2))
|
|
328
|
+
return
|
|
329
|
+
if args.source == 'build':
|
|
330
|
+
if data.get('error_summary'):
|
|
331
|
+
print(f"error summary: {data['error_summary']}\n")
|
|
332
|
+
for t in data.get('tasks') or []:
|
|
333
|
+
print(f"task {t['id']} [{t['status']}] {t['title']}")
|
|
334
|
+
if data.get('tasks'):
|
|
335
|
+
print()
|
|
336
|
+
shown = data['lines']
|
|
337
|
+
for ln in shown:
|
|
338
|
+
print(ln)
|
|
339
|
+
if not args.follow:
|
|
340
|
+
if data.get('truncated'):
|
|
341
|
+
print(f"... (showing last {len(shown)} lines; use --lines to see more)", file=sys.stderr)
|
|
342
|
+
return
|
|
343
|
+
# Follow: poll and print only what is new. Build and test logs end when
|
|
344
|
+
# the build settles; the odoo source is a running container's stdout, so
|
|
345
|
+
# it follows until Ctrl-C (like `docker logs -f`). Each odoo poll runs a
|
|
346
|
+
# small playbook on the host, so it is polled no faster than every 15s.
|
|
347
|
+
interval = max(args.interval, 15.0) if args.source == 'odoo' else args.interval
|
|
348
|
+
if args.source == 'odoo':
|
|
349
|
+
print(f'following container logs every {interval:.0f}s; Ctrl-C to stop', file=sys.stderr)
|
|
350
|
+
previous = shown
|
|
351
|
+
try:
|
|
352
|
+
while True:
|
|
353
|
+
time.sleep(interval)
|
|
354
|
+
data = client.request('GET', f'/builds/{args.build_id}/logs', params=params)
|
|
355
|
+
current = data['lines']
|
|
356
|
+
for ln in _new_lines(previous, current):
|
|
357
|
+
print(ln, flush=True)
|
|
358
|
+
previous = current
|
|
359
|
+
if args.source != 'odoo' and \
|
|
360
|
+
(data.get('build_status') or '').lower() in _TERMINAL_BUILD_STATUSES:
|
|
361
|
+
print(f"build {args.build_id} is {data['build_status']}", file=sys.stderr)
|
|
362
|
+
return
|
|
363
|
+
except KeyboardInterrupt:
|
|
364
|
+
print('', file=sys.stderr)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_branch_create(args):
|
|
369
|
+
body = {'name': args.name}
|
|
370
|
+
if args.base_branch is not None:
|
|
371
|
+
body['base_branch_id'] = args.base_branch
|
|
372
|
+
data = _client(args).request('POST', f'/projects/{args.project}/branches', json=body)
|
|
373
|
+
b = data['branch']
|
|
374
|
+
print(f"branch {b['name']} (id {b['id']}) created from {b['base_branch']}; "
|
|
375
|
+
f"build {data['build_id']} queued at {data['fqdn']}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_update_modules(args):
|
|
379
|
+
body = {'confirm': args.confirm, 'include_reverse_deps': args.reverse_deps}
|
|
380
|
+
if args.modules:
|
|
381
|
+
body['modules'] = [m for chunk in args.modules for m in chunk.split(',') if m]
|
|
382
|
+
if args.theme is not None:
|
|
383
|
+
body['install_theme'] = args.theme
|
|
384
|
+
data = _client(args).request('POST', f'/builds/{args.build_id}/update-modules', json=body)
|
|
385
|
+
theme = ' with INSTALL_THEME=1' if data.get('install_theme') else ''
|
|
386
|
+
print(f"update queued (task {data['task_id']}): {', '.join(data['modules'])}{theme}")
|
|
387
|
+
if not args.wait:
|
|
388
|
+
print(f"poll with: doploy task {data['task_id']}")
|
|
389
|
+
return
|
|
390
|
+
task = _wait_for_task(_client(args), data['task_id'], args.interval)
|
|
391
|
+
_print_task(task)
|
|
392
|
+
if task['status'] != 'completed' or (task.get('result') or {}).get('ok') is False:
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _wait_for_task(client: Client, task_id: int, interval: float) -> dict:
|
|
397
|
+
while True:
|
|
398
|
+
task = client.request('GET', f'/tasks/{task_id}')['task']
|
|
399
|
+
if task['status'] in ('completed', 'failed', 'cancelled'):
|
|
400
|
+
return task
|
|
401
|
+
time.sleep(interval)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _print_task(task: dict) -> None:
|
|
405
|
+
print(f"task {task['id']} [{task['status']}] {task['title']}")
|
|
406
|
+
result = task.get('result')
|
|
407
|
+
if result:
|
|
408
|
+
if 'exit_code' in result:
|
|
409
|
+
print(f"exit code: {result['exit_code']}")
|
|
410
|
+
if result.get('error'):
|
|
411
|
+
print(f"error: {result['error']}")
|
|
412
|
+
if result.get('output'):
|
|
413
|
+
print(result['output'])
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cmd_task(args):
|
|
417
|
+
client = _client(args)
|
|
418
|
+
task = _wait_for_task(client, args.task_id, args.interval) if args.wait \
|
|
419
|
+
else client.request('GET', f'/tasks/{args.task_id}')['task']
|
|
420
|
+
if args.json:
|
|
421
|
+
print(json.dumps(task, indent=2))
|
|
422
|
+
return
|
|
423
|
+
_print_task(task)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def cmd_backup(args):
|
|
427
|
+
body = {'confirm': args.confirm}
|
|
428
|
+
if args.comment:
|
|
429
|
+
body['comment'] = args.comment
|
|
430
|
+
client = _client(args)
|
|
431
|
+
data = client.request('POST', f'/builds/{args.build_id}/backup', json=body)
|
|
432
|
+
backup = data['backup']
|
|
433
|
+
print(f"backup {backup['id']} started ({backup['status']})")
|
|
434
|
+
if not args.wait:
|
|
435
|
+
return
|
|
436
|
+
while backup['status'] == 'running':
|
|
437
|
+
time.sleep(args.interval)
|
|
438
|
+
backup = client.request('GET', f"/backups/{backup['id']}")['backup']
|
|
439
|
+
print(f"backup {backup['id']} {backup['status']}"
|
|
440
|
+
+ (f", {backup['size_mb']} MB" if backup.get('size_mb') else ''))
|
|
441
|
+
if backup['status'] != 'completed':
|
|
442
|
+
sys.exit(1)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def main(argv=None):
|
|
446
|
+
p = argparse.ArgumentParser(prog='doploy', description=__doc__.splitlines()[0])
|
|
447
|
+
p.add_argument('--token', help=argparse.SUPPRESS)
|
|
448
|
+
p.add_argument('--base', help=f'API base URL (default {DEFAULT_BASE})')
|
|
449
|
+
sub = p.add_subparsers(dest='command', required=True)
|
|
450
|
+
|
|
451
|
+
sp = sub.add_parser('login', help='sign in with your browser')
|
|
452
|
+
sp.add_argument('token_value', nargs='?', help=argparse.SUPPRESS)
|
|
453
|
+
sp.add_argument('--with-token', action='store_true', help=argparse.SUPPRESS)
|
|
454
|
+
sp.set_defaults(func=cmd_login)
|
|
455
|
+
|
|
456
|
+
sp = sub.add_parser('logout', help='forget saved credentials')
|
|
457
|
+
sp.set_defaults(func=cmd_logout)
|
|
458
|
+
|
|
459
|
+
sp = sub.add_parser('projects', help='list your projects')
|
|
460
|
+
sp.add_argument('--json', action='store_true')
|
|
461
|
+
sp.set_defaults(func=cmd_projects)
|
|
462
|
+
|
|
463
|
+
sp = sub.add_parser('builds', help='list builds for a project')
|
|
464
|
+
sp.add_argument('project', help='project id (from `doploy projects`)')
|
|
465
|
+
sp.add_argument('--type', choices=['development', 'staging', 'production'])
|
|
466
|
+
sp.add_argument('--limit', type=int, default=20)
|
|
467
|
+
sp.add_argument('--json', action='store_true')
|
|
468
|
+
sp.set_defaults(func=cmd_builds)
|
|
469
|
+
|
|
470
|
+
sp = sub.add_parser('build', help='show one build')
|
|
471
|
+
sp.add_argument('build_id', type=int)
|
|
472
|
+
sp.set_defaults(func=cmd_build)
|
|
473
|
+
|
|
474
|
+
sp = sub.add_parser('tests', help='test results for a build')
|
|
475
|
+
sp.add_argument('build_id', type=int)
|
|
476
|
+
sp.add_argument('--details', action='store_true', help='print failure details')
|
|
477
|
+
sp.add_argument('--warnings', action='store_true', help='include warnings')
|
|
478
|
+
sp.add_argument('--json', action='store_true')
|
|
479
|
+
sp.set_defaults(func=cmd_tests)
|
|
480
|
+
|
|
481
|
+
sp = sub.add_parser('rebuild', help='queue a rebuild (deploy scope)')
|
|
482
|
+
sp.add_argument('build_id', type=int)
|
|
483
|
+
sp.add_argument('--confirm', action='store_true',
|
|
484
|
+
help='required when targeting production')
|
|
485
|
+
sp.set_defaults(func=cmd_rebuild)
|
|
486
|
+
|
|
487
|
+
sp = sub.add_parser('restart', help='restart the Odoo service (deploy scope)')
|
|
488
|
+
sp.add_argument('build_id', type=int)
|
|
489
|
+
sp.add_argument('--confirm', action='store_true',
|
|
490
|
+
help='required when targeting production')
|
|
491
|
+
sp.set_defaults(func=cmd_restart)
|
|
492
|
+
|
|
493
|
+
sp = sub.add_parser('logs', help='tail build logs (build, test or odoo source)')
|
|
494
|
+
sp.add_argument('build_id', type=int)
|
|
495
|
+
sp.add_argument('--source', choices=['build', 'test', 'odoo'], default='build')
|
|
496
|
+
sp.add_argument('--lines', type=int, default=200, help='tail length (max 2000)')
|
|
497
|
+
sp.add_argument('--filter', help='only lines containing this text')
|
|
498
|
+
sp.add_argument('-f', '--follow', action='store_true',
|
|
499
|
+
help='keep printing new lines: build/test until the build settles, '
|
|
500
|
+
'odoo until Ctrl-C')
|
|
501
|
+
sp.add_argument('--interval', type=float, default=5.0,
|
|
502
|
+
help='poll seconds for --follow (odoo source: minimum 15)')
|
|
503
|
+
sp.add_argument('--json', action='store_true')
|
|
504
|
+
sp.set_defaults(func=cmd_logs)
|
|
505
|
+
|
|
506
|
+
sp = sub.add_parser('branch', help='branch operations')
|
|
507
|
+
bsub = sp.add_subparsers(dest='branch_command', required=True)
|
|
508
|
+
bp = bsub.add_parser('create', help='fork a dev branch and queue its build (deploy scope)')
|
|
509
|
+
bp.add_argument('project', help='project id')
|
|
510
|
+
bp.add_argument('name', help='new branch name')
|
|
511
|
+
bp.add_argument('--from', dest='base_branch', type=int,
|
|
512
|
+
help='base branch id (default: production branch)')
|
|
513
|
+
bp.set_defaults(func=cmd_branch_create)
|
|
514
|
+
|
|
515
|
+
sp = sub.add_parser('update-modules',
|
|
516
|
+
help='run odoo-bin -u on a deployment (deploy scope)')
|
|
517
|
+
sp.add_argument('build_id', type=int)
|
|
518
|
+
sp.add_argument('modules', nargs='*',
|
|
519
|
+
help='module names (default: modules doploy detected as changed)')
|
|
520
|
+
sp.add_argument('--reverse-deps', action='store_true',
|
|
521
|
+
help='also update detected reverse-dependency modules')
|
|
522
|
+
sp.add_argument('--theme', dest='theme', action='store_true', default=None,
|
|
523
|
+
help='rebuild theme assets (INSTALL_THEME=1); default: doploy decides')
|
|
524
|
+
sp.add_argument('--no-theme', dest='theme', action='store_false',
|
|
525
|
+
help='never rebuild theme assets')
|
|
526
|
+
sp.add_argument('--confirm', action='store_true',
|
|
527
|
+
help='required when targeting production')
|
|
528
|
+
sp.add_argument('--wait', action='store_true', help='block until the update finishes')
|
|
529
|
+
sp.add_argument('--interval', type=float, default=10.0, help='poll seconds for --wait')
|
|
530
|
+
sp.set_defaults(func=cmd_update_modules)
|
|
531
|
+
|
|
532
|
+
sp = sub.add_parser('task', help='show a queued task (module update)')
|
|
533
|
+
sp.add_argument('task_id', type=int)
|
|
534
|
+
sp.add_argument('--wait', action='store_true', help='block until it finishes')
|
|
535
|
+
sp.add_argument('--interval', type=float, default=10.0)
|
|
536
|
+
sp.add_argument('--json', action='store_true')
|
|
537
|
+
sp.set_defaults(func=cmd_task)
|
|
538
|
+
|
|
539
|
+
sp = sub.add_parser('backup', help='start a backup of a deployment (deploy scope)')
|
|
540
|
+
sp.add_argument('build_id', type=int)
|
|
541
|
+
sp.add_argument('--comment')
|
|
542
|
+
sp.add_argument('--confirm', action='store_true',
|
|
543
|
+
help='required when targeting production')
|
|
544
|
+
sp.add_argument('--wait', action='store_true', help='block until the backup finishes')
|
|
545
|
+
sp.add_argument('--interval', type=float, default=15.0)
|
|
546
|
+
sp.set_defaults(func=cmd_backup)
|
|
547
|
+
|
|
548
|
+
args = p.parse_args(argv)
|
|
549
|
+
args.func(args)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
if __name__ == '__main__':
|
|
553
|
+
main()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: doploy-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal or CI
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://doploy.io
|
|
7
|
+
Project-URL: Documentation, https://docs.doploy.io
|
|
8
|
+
Project-URL: Dashboard, https://cloud.doploy.io
|
|
9
|
+
Keywords: doploy,odoo,deploy,ci,cli
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# doploy CLI
|
|
24
|
+
|
|
25
|
+
Watch builds, read logs, and drive your Odoo deployments on [doploy](https://doploy.io)
|
|
26
|
+
from a terminal or a CI pipeline.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install doploy-cli
|
|
30
|
+
doploy login # opens your browser; no tokens to paste
|
|
31
|
+
doploy projects
|
|
32
|
+
doploy builds <project-id> --type production
|
|
33
|
+
doploy logs <build-id> --source test -f
|
|
34
|
+
doploy update-modules <build-id> --wait
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Auth
|
|
38
|
+
|
|
39
|
+
`doploy login` signs you in with your doploy account (OAuth, PKCE, loopback
|
|
40
|
+
callback). Tokens refresh themselves and live in `~/.config/doploy/config.json`
|
|
41
|
+
(mode 0600). Whether the CLI may deploy is controlled per app in
|
|
42
|
+
Settings > Connected apps.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `projects` | List your projects |
|
|
50
|
+
| `builds <project>` | Recent builds, filter with `--type` |
|
|
51
|
+
| `build <id>` | One build in full |
|
|
52
|
+
| `tests <build>` | Test failures, `--details` for tracebacks |
|
|
53
|
+
| `logs <build>` | Tail logs; `--source build\|test\|odoo`, `-f` to follow |
|
|
54
|
+
| `branch create <project> <name>` | Fork a dev branch and queue its build |
|
|
55
|
+
| `rebuild <build>` | Queue a rebuild |
|
|
56
|
+
| `restart <build>` | Restart the Odoo service |
|
|
57
|
+
| `update-modules <build> [modules]` | Run `odoo-bin -u`; `--theme`, `--wait` |
|
|
58
|
+
| `backup <build>` | Start a backup; `--wait` |
|
|
59
|
+
| `task <id>` | Poll a queued task |
|
|
60
|
+
| `logout` | Forget saved credentials |
|
|
61
|
+
|
|
62
|
+
Production targets always need `--confirm`. Destructive operations
|
|
63
|
+
(deletes, restores) are not exposed.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
doploy_cli/__init__.py
|
|
5
|
+
doploy_cli/main.py
|
|
6
|
+
doploy_cli.egg-info/PKG-INFO
|
|
7
|
+
doploy_cli.egg-info/SOURCES.txt
|
|
8
|
+
doploy_cli.egg-info/dependency_links.txt
|
|
9
|
+
doploy_cli.egg-info/entry_points.txt
|
|
10
|
+
doploy_cli.egg-info/requires.txt
|
|
11
|
+
doploy_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
doploy_cli
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "doploy-cli"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal or CI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["httpx>=0.27"]
|
|
13
|
+
keywords = ["doploy", "odoo", "deploy", "ci", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Software Development :: Build Tools",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://doploy.io"
|
|
26
|
+
Documentation = "https://docs.doploy.io"
|
|
27
|
+
Dashboard = "https://cloud.doploy.io"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
doploy = "doploy_cli.main:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["doploy_cli*"]
|