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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ doploy = doploy_cli.main:main
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+