odooflow-cli 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/PKG-INFO +83 -6
  2. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/README.md +82 -5
  3. odooflow_cli-0.3.0/odooflow/__init__.py +1 -0
  4. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/cli.py +16 -4
  5. odooflow_cli-0.3.0/odooflow/commands/clone_module.py +273 -0
  6. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/commands/config.py +38 -10
  7. odooflow_cli-0.3.0/odooflow/commands/gitlab.py +84 -0
  8. odooflow_cli-0.3.0/odooflow/commands/init_module_env.py +80 -0
  9. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/commands/push.py +21 -4
  10. odooflow_cli-0.3.0/odooflow/commands/remote.py +238 -0
  11. odooflow_cli-0.3.0/odooflow/commands/server.py +559 -0
  12. odooflow_cli-0.3.0/odooflow/commands/setup.py +131 -0
  13. odooflow_cli-0.3.0/odooflow/config_manager.py +198 -0
  14. odooflow_cli-0.3.0/odooflow/errors.py +308 -0
  15. odooflow_cli-0.3.0/odooflow/utils/env.py +80 -0
  16. odooflow_cli-0.3.0/odooflow/utils/server_profile.py +319 -0
  17. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow_cli.egg-info/PKG-INFO +83 -6
  18. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow_cli.egg-info/SOURCES.txt +9 -0
  19. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow_cli.egg-info/top_level.txt +1 -0
  20. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/pyproject.toml +1 -1
  21. odooflow_cli-0.3.0/tests/test_clone_module.py +219 -0
  22. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_commands_remote.py +4 -2
  23. odooflow_cli-0.3.0/tests/test_commands_server.py +504 -0
  24. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_config_manager.py +63 -15
  25. odooflow_cli-0.3.0/tests/test_gitlab.py +110 -0
  26. odooflow_cli-0.3.0/tests/test_server_profile.py +339 -0
  27. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_utils_env.py +14 -14
  28. odooflow_cli-0.2.0/odooflow/__init__.py +0 -1
  29. odooflow_cli-0.2.0/odooflow/commands/clone_module.py +0 -160
  30. odooflow_cli-0.2.0/odooflow/commands/init_module_env.py +0 -37
  31. odooflow_cli-0.2.0/odooflow/commands/remote.py +0 -133
  32. odooflow_cli-0.2.0/odooflow/config_manager.py +0 -56
  33. odooflow_cli-0.2.0/odooflow/utils/env.py +0 -40
  34. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/LICENSE +0 -0
  35. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/commands/__init__.py +0 -0
  36. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/commands/keygen.py +0 -0
  37. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/commands/sync_env.py +0 -0
  38. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow/utils/ssh.py +0 -0
  39. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow_cli.egg-info/dependency_links.txt +0 -0
  40. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow_cli.egg-info/entry_points.txt +0 -0
  41. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/odooflow_cli.egg-info/requires.txt +0 -0
  42. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/setup.cfg +0 -0
  43. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/__init__.py +0 -0
  44. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_commands_config.py +0 -0
  45. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_commands_init_module_env.py +0 -0
  46. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_commands_keygen.py +0 -0
  47. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_commands_sync_env.py +0 -0
  48. {odooflow_cli-0.2.0 → odooflow_cli-0.3.0}/tests/test_utils_ssh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odooflow-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: OdooFlow CLI - streamline your Odoo development workflow
5
5
  Author: Mohammad A. Hamdan
6
6
  License: MIT
@@ -42,13 +42,14 @@ Dynamic: license-file
42
42
  - Smart skip of Odoo core modules
43
43
  - Branch selection for cloning
44
44
  - Post-push command execution on the remote server
45
+ - **Named server profiles** (staging/QA/prod) with first-class `odooflow server list|add|show|use|remove|test`
45
46
  - Built-in SSH key generation
46
47
  - Helpful and colorful CLI output
47
48
  - Built using [Typer](https://typer.tiangolo.com/) and Python 3.7+
48
49
 
49
50
  ---
50
51
 
51
- ## 📦 Installation
52
+ ## 📦 Installation & first-run setup
52
53
 
53
54
  ```bash
54
55
  git clone https://github.com/anomalyco/odooflow-cli.git
@@ -68,6 +69,29 @@ Install from PyPI (once published):
68
69
  pip install odooflow-cli
69
70
  ```
70
71
 
72
+ ### First-run wizard
73
+
74
+ After installing, configure odooflow with your GitLab access token:
75
+
76
+ ```bash
77
+ odooflow setup
78
+ ```
79
+
80
+ The wizard writes `~/.odooflowrc` (with `chmod 600` permissions), prompting for:
81
+
82
+ 1. **GitLab access token** — kept private in the rc file; typed input is masked.
83
+ 2. **GitLab URL** — defaults to the bundled one, override for self-hosted.
84
+ 3. **Core modules** — comma-separated list, used to skip framework deps.
85
+
86
+ If you don't have a token yet, create one at *GitLab → Preferences → Access Tokens* with scopes `api`, `read_api`, and `write_repository`.
87
+
88
+ Prefer environment variables? You can skip the rc entirely:
89
+
90
+ ```bash
91
+ export ODOOFLOW_ACCESS_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
92
+ odooflow clone <your-module-url>
93
+ ```
94
+
71
95
  ---
72
96
 
73
97
  ## 🛠️ Usage
@@ -80,11 +104,13 @@ odooflow --help
80
104
 
81
105
  ### Available Commands:
82
106
 
107
+ - **`setup`**: Interactive wizard for first-run configuration (`~/.odooflowrc`).
83
108
  - **`init`**: Initialize the Odoo module environment file and sync metadata with manifest
84
109
  - **`sync-env`**: Sync the environment file from manifest
85
110
  - **`config`**: Update or show OdooFlow CLI configuration
86
111
  - **`clone`**: Clone a module and its dependencies from a git repository
87
112
  - **`remote`**: Manage remote connections for Git and deployment server
113
+ - **`server`**: Manage named server profiles (staging/QA/prod) — `list`, `add`, `show`, `use`, `remove`, `test`
88
114
  - **`ssh-keygen`**: Generate a secure SSH key pair
89
115
  - **`push`**: Push the current Git branch and upload the project to the test server
90
116
 
@@ -98,10 +124,23 @@ odooflow --help
98
124
 
99
125
  ### Push Command Options:
100
126
 
101
- | Flag | Description |
102
- |-----------------|-----------------------------------------------------------------------------|
103
- | `--remote-only` | Skip Git push and only upload to server |
104
- | `--exec` | Custom shell command to execute on the server after pushing |
127
+ | Flag | Description |
128
+ |-----------------|----------------------------------------------------------------------------------------------------------|
129
+ | `--server`/`-s` | Named server profile from `odooflow server list` (defaults to the configured default). |
130
+ | `--remote-only` | Skip Git push and only upload to server |
131
+ | `--exec` | Custom shell command to execute on the server after pushing |
132
+
133
+ ### Server Profile Commands:
134
+
135
+ | Command | What it does |
136
+ |--------------------------------------|-------------------------------------------------------|
137
+ | `odooflow server list` | Tabular view of every configured profile. |
138
+ | `odooflow server list --json` | Machine-readable output (passwords omitted). |
139
+ | `odooflow server add <name>` | Interactive wizard (or `--host`, `--user`, `--key-path` for non-interactive). Validates inputs and tests SSH on save. |
140
+ | `odooflow server show [<name>]` | Show fields of a profile. Default = the current default. Passwords are masked unless `--reveal-password`. |
141
+ | `odooflow server use <name>` | Set the default profile used by `odooflow push`. |
142
+ | `odooflow server remove <name>` | Delete a profile (the default reverts to another if any are left). |
143
+ | `odooflow server test [<name>]` | Verify TCP reachability, SSH auth, and directory existence without uploading anything. |
105
144
 
106
145
  ### 🔍 Examples:
107
146
 
@@ -147,6 +186,42 @@ Skip Git push, only upload to server:
147
186
  odooflow push --remote-only
148
187
  ```
149
188
 
189
+ Push to a specific server (when you have several profiles):
190
+
191
+ ```bash
192
+ odooflow push --server staging
193
+ odooflow push --server prod --remote-only --exec 'sudo systemctl restart odoo-prod'
194
+ ```
195
+
196
+ ### 📡 Server profiles — a faster flow for `staging` / `qa` / `prod`
197
+
198
+ Add as many named profiles as you want. The first one you create is the default for `odooflow push`:
199
+
200
+ ```bash
201
+ # Interactive wizard — validates every field and tests SSH before saving.
202
+ odooflow server add staging
203
+
204
+ # Non-interactive / scriptable:
205
+ odooflow server add qa \
206
+ --host 10.0.0.5 --port 22 --user deploy \
207
+ --directory /opt/odoo/qa --key-path ~/.ssh/odooflow_rsa
208
+
209
+ # Review what you have:
210
+ odooflow server list
211
+ odooflow server show staging # password is masked; --reveal-password to see it
212
+
213
+ # Switch the default:
214
+ odooflow server use prod
215
+
216
+ # Verify connectivity without uploading:
217
+ odooflow server test staging
218
+
219
+ # Reorder / remove:
220
+ odooflow server remove qa
221
+ ```
222
+
223
+ Existing single-server configs are auto-migrated into a `default` profile on first `odooflow server add`, so nothing you've already configured is lost.
224
+
150
225
  ---
151
226
 
152
227
  ## 📁 Project Structure
@@ -165,9 +240,11 @@ odooflow/
165
240
  │ │ ├── keygen.py
166
241
  │ │ ├── push.py
167
242
  │ │ ├── remote.py
243
+ │ │ ├── server.py
168
244
  │ │ └── sync_env.py
169
245
  │ └── utils/
170
246
  │ ├── env.py
247
+ │ ├── server_profile.py
171
248
  │ └── ssh.py
172
249
  ├── tests/
173
250
  ├── README.md
@@ -9,13 +9,14 @@
9
9
  - Smart skip of Odoo core modules
10
10
  - Branch selection for cloning
11
11
  - Post-push command execution on the remote server
12
+ - **Named server profiles** (staging/QA/prod) with first-class `odooflow server list|add|show|use|remove|test`
12
13
  - Built-in SSH key generation
13
14
  - Helpful and colorful CLI output
14
15
  - Built using [Typer](https://typer.tiangolo.com/) and Python 3.7+
15
16
 
16
17
  ---
17
18
 
18
- ## 📦 Installation
19
+ ## 📦 Installation & first-run setup
19
20
 
20
21
  ```bash
21
22
  git clone https://github.com/anomalyco/odooflow-cli.git
@@ -35,6 +36,29 @@ Install from PyPI (once published):
35
36
  pip install odooflow-cli
36
37
  ```
37
38
 
39
+ ### First-run wizard
40
+
41
+ After installing, configure odooflow with your GitLab access token:
42
+
43
+ ```bash
44
+ odooflow setup
45
+ ```
46
+
47
+ The wizard writes `~/.odooflowrc` (with `chmod 600` permissions), prompting for:
48
+
49
+ 1. **GitLab access token** — kept private in the rc file; typed input is masked.
50
+ 2. **GitLab URL** — defaults to the bundled one, override for self-hosted.
51
+ 3. **Core modules** — comma-separated list, used to skip framework deps.
52
+
53
+ If you don't have a token yet, create one at *GitLab → Preferences → Access Tokens* with scopes `api`, `read_api`, and `write_repository`.
54
+
55
+ Prefer environment variables? You can skip the rc entirely:
56
+
57
+ ```bash
58
+ export ODOOFLOW_ACCESS_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
59
+ odooflow clone <your-module-url>
60
+ ```
61
+
38
62
  ---
39
63
 
40
64
  ## 🛠️ Usage
@@ -47,11 +71,13 @@ odooflow --help
47
71
 
48
72
  ### Available Commands:
49
73
 
74
+ - **`setup`**: Interactive wizard for first-run configuration (`~/.odooflowrc`).
50
75
  - **`init`**: Initialize the Odoo module environment file and sync metadata with manifest
51
76
  - **`sync-env`**: Sync the environment file from manifest
52
77
  - **`config`**: Update or show OdooFlow CLI configuration
53
78
  - **`clone`**: Clone a module and its dependencies from a git repository
54
79
  - **`remote`**: Manage remote connections for Git and deployment server
80
+ - **`server`**: Manage named server profiles (staging/QA/prod) — `list`, `add`, `show`, `use`, `remove`, `test`
55
81
  - **`ssh-keygen`**: Generate a secure SSH key pair
56
82
  - **`push`**: Push the current Git branch and upload the project to the test server
57
83
 
@@ -65,10 +91,23 @@ odooflow --help
65
91
 
66
92
  ### Push Command Options:
67
93
 
68
- | Flag | Description |
69
- |-----------------|-----------------------------------------------------------------------------|
70
- | `--remote-only` | Skip Git push and only upload to server |
71
- | `--exec` | Custom shell command to execute on the server after pushing |
94
+ | Flag | Description |
95
+ |-----------------|----------------------------------------------------------------------------------------------------------|
96
+ | `--server`/`-s` | Named server profile from `odooflow server list` (defaults to the configured default). |
97
+ | `--remote-only` | Skip Git push and only upload to server |
98
+ | `--exec` | Custom shell command to execute on the server after pushing |
99
+
100
+ ### Server Profile Commands:
101
+
102
+ | Command | What it does |
103
+ |--------------------------------------|-------------------------------------------------------|
104
+ | `odooflow server list` | Tabular view of every configured profile. |
105
+ | `odooflow server list --json` | Machine-readable output (passwords omitted). |
106
+ | `odooflow server add <name>` | Interactive wizard (or `--host`, `--user`, `--key-path` for non-interactive). Validates inputs and tests SSH on save. |
107
+ | `odooflow server show [<name>]` | Show fields of a profile. Default = the current default. Passwords are masked unless `--reveal-password`. |
108
+ | `odooflow server use <name>` | Set the default profile used by `odooflow push`. |
109
+ | `odooflow server remove <name>` | Delete a profile (the default reverts to another if any are left). |
110
+ | `odooflow server test [<name>]` | Verify TCP reachability, SSH auth, and directory existence without uploading anything. |
72
111
 
73
112
  ### 🔍 Examples:
74
113
 
@@ -114,6 +153,42 @@ Skip Git push, only upload to server:
114
153
  odooflow push --remote-only
115
154
  ```
116
155
 
156
+ Push to a specific server (when you have several profiles):
157
+
158
+ ```bash
159
+ odooflow push --server staging
160
+ odooflow push --server prod --remote-only --exec 'sudo systemctl restart odoo-prod'
161
+ ```
162
+
163
+ ### 📡 Server profiles — a faster flow for `staging` / `qa` / `prod`
164
+
165
+ Add as many named profiles as you want. The first one you create is the default for `odooflow push`:
166
+
167
+ ```bash
168
+ # Interactive wizard — validates every field and tests SSH before saving.
169
+ odooflow server add staging
170
+
171
+ # Non-interactive / scriptable:
172
+ odooflow server add qa \
173
+ --host 10.0.0.5 --port 22 --user deploy \
174
+ --directory /opt/odoo/qa --key-path ~/.ssh/odooflow_rsa
175
+
176
+ # Review what you have:
177
+ odooflow server list
178
+ odooflow server show staging # password is masked; --reveal-password to see it
179
+
180
+ # Switch the default:
181
+ odooflow server use prod
182
+
183
+ # Verify connectivity without uploading:
184
+ odooflow server test staging
185
+
186
+ # Reorder / remove:
187
+ odooflow server remove qa
188
+ ```
189
+
190
+ Existing single-server configs are auto-migrated into a `default` profile on first `odooflow server add`, so nothing you've already configured is lost.
191
+
117
192
  ---
118
193
 
119
194
  ## 📁 Project Structure
@@ -132,9 +207,11 @@ odooflow/
132
207
  │ │ ├── keygen.py
133
208
  │ │ ├── push.py
134
209
  │ │ ├── remote.py
210
+ │ │ ├── server.py
135
211
  │ │ └── sync_env.py
136
212
  │ └── utils/
137
213
  │ ├── env.py
214
+ │ ├── server_profile.py
138
215
  │ └── ssh.py
139
216
  ├── tests/
140
217
  ├── README.md
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -8,8 +8,16 @@ from odooflow.commands.clone_module import clone_module_command
8
8
  from odooflow.commands.remote import remote as remote_command
9
9
  from odooflow.commands.keygen import generate_ssh_key as keygen_command
10
10
  from odooflow.commands.push import push_command
11
+ from odooflow.commands.setup import setup as setup_command
12
+ from odooflow.commands.server import app as server_app
11
13
 
12
14
  app = typer.Typer(help="OdooFlow CLI — streamline your Odoo development workflow.")
15
+ app.add_typer(server_app, name="server")
16
+
17
+ @app.command(name="setup")
18
+ def setup_cmd():
19
+ """Interactive wizard: write ~/.odooflowrc with token, GitLab URL, core modules."""
20
+ setup_command()
13
21
 
14
22
  @app.command(name="init")
15
23
  def init_manifest(
@@ -51,12 +59,15 @@ def config(
51
59
  def clone_command(
52
60
  repo_url: str = typer.Argument(..., help="HTTP URL of the module repository."),
53
61
  branch: Optional[str] = typer.Option(None, "--branch", '-b', help="Branch to clone"),
54
- depth: int = typer.Option(1, "--depth", "-d", help="Max dependency depth to clone. 1 clones only the target module, 2 clones target + immediate dependencies, etc.")
62
+ depth: int = typer.Option(1, "--depth", "-d", help="Max dependency depth to clone. 1 = target only, 2 = target + immediate deps, etc."),
63
+ workers: int = typer.Option(4, "--workers", "-w", help="Max concurrent clones (1-8)."),
55
64
  ):
56
65
  """
57
- Clone a module and its dependencies from a git repository.
66
+ Clone a module and (optionally) its dependencies from a Git repository.
67
+
68
+ Run `odooflow setup` first if you have not configured an access token yet.
58
69
  """
59
- clone_module_command(repo_url, branch, depth)
70
+ clone_module_command(repo_url, branch, depth, workers)
60
71
 
61
72
 
62
73
  @app.command()
@@ -88,13 +99,14 @@ def generate_ssh_key(
88
99
 
89
100
  @app.command()
90
101
  def push(
102
+ server: Optional[str] = typer.Option(None, "--server", "-s", help="Named server profile from `odooflow server list`."),
91
103
  remote_only: bool = typer.Option(False, "--remote-only", help="Skip Git push and only upload to server"),
92
104
  exec_cmd: Optional[str] = typer.Option(None, "--exec", help="Custom shell command to execute on the server after pushing"),
93
105
  ):
94
106
  """
95
107
  Push the current Git branch and upload the project to the test server.
96
108
  """
97
- push_command(remote_only=remote_only, exec_cmd=exec_cmd)
109
+ push_command(server_name=server, remote_only=remote_only, exec_cmd=exec_cmd)
98
110
 
99
111
 
100
112
 
@@ -0,0 +1,273 @@
1
+ import ast
2
+ import typer
3
+ import requests
4
+ import threading
5
+
6
+ from typing import Optional
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ from urllib.parse import urlparse, urlunparse
10
+ from git import Repo, GitCommandError
11
+ from pathlib import Path
12
+
13
+ from odooflow import errors
14
+ from odooflow.commands.gitlab import get_default_branch
15
+ from odooflow.config_manager import (
16
+ get_access_token,
17
+ get_core_modules_from_config,
18
+ load_config,
19
+ )
20
+
21
+
22
+ def get_project_url_from_gitlab(module_name: str, base_url: Optional[str] = None) -> Optional[str]:
23
+ """Search GitLab for a project by name and return its HTTPS URL."""
24
+ if base_url is None:
25
+ config = load_config(strict=False)
26
+ base_url = config.get("gitlab_url", "https://gitlab.ebtech-solution.com")
27
+
28
+ api_url = f"{base_url}/api/v4/projects"
29
+ headers = {"Accept": "application/json"}
30
+
31
+ try:
32
+ token = get_access_token()
33
+ except errors.AccessTokenMissingError:
34
+ errors.access_token_missing_rc_fallback()
35
+ return None
36
+
37
+ params = {"search": module_name, "simple": "true", "per_page": 100, "access_token": token}
38
+
39
+ try:
40
+ typer.secho(f" 🔍 Looking up '{module_name}' in GitLab…", fg="cyan")
41
+ response = requests.get(api_url, headers=headers, params=params, timeout=15)
42
+ response.raise_for_status()
43
+
44
+ for project in response.json():
45
+ if project.get("name") == module_name or project.get("path") == module_name:
46
+ typer.secho(f" ✓ Resolved '{module_name}'", fg="green")
47
+ return project["http_url_to_repo"]
48
+
49
+ errors.dependency_unresolved(module_name)
50
+ return None
51
+
52
+ except requests.RequestException as e:
53
+ errors.gitlab_unreachable(base_url, str(e))
54
+ return None
55
+
56
+
57
+ def inject_token_into_url(url: str, token: str) -> str:
58
+ """Embed a GitLab PAT into a clone URL as `oauth2:<token>@host`."""
59
+ parsed = urlparse(url)
60
+ if not parsed.netloc:
61
+ raise ValueError(f"Invalid URL: {url}")
62
+ netloc = f"oauth2:{token}@{parsed.netloc}"
63
+ return urlunparse(parsed._replace(netloc=netloc, scheme="https"))
64
+
65
+
66
+ def extract_module_name_from_url(url: str) -> str:
67
+ return url.rstrip("/").split("/")[-1].removesuffix(".git")
68
+
69
+
70
+ def safe_eval_manifest(content: str) -> dict:
71
+ try:
72
+ return ast.literal_eval(content)
73
+ except (SyntaxError, ValueError) as e:
74
+ errors._emit(
75
+ "Manifest could not be parsed.",
76
+ [
77
+ f" Python error: {e}",
78
+ "",
79
+ " Treat the module as having no dependencies; fix the manifest",
80
+ " in the cloned copy before re-running with --depth > 1.",
81
+ ],
82
+ )
83
+ return {}
84
+
85
+
86
+ def get_access_token_safe() -> bool:
87
+ try:
88
+ get_access_token()
89
+ return True
90
+ except errors.AccessTokenMissingError:
91
+ return False
92
+
93
+
94
+ def _resolve_branch(repo_url: str, requested: Optional[str]) -> Optional[str]:
95
+ """
96
+ Decide which branch to pass to `git clone -b <branch>`.
97
+
98
+ Resolution order:
99
+ 1. Caller-supplied branch (CLI `--branch` or per-dependency value) wins.
100
+ 2. GitLab API `default_branch` for the project's URL.
101
+ 3. None — caller passes no `-b` flag and git uses its own default.
102
+ """
103
+ if requested:
104
+ return requested
105
+ try:
106
+ default = get_default_branch(repo_url)
107
+ except Exception:
108
+ default = None
109
+ return default or None
110
+
111
+
112
+ def clone_repo(url: str, target_dir: Path, branch: str = None) -> bool:
113
+ """
114
+ Clone `url` into `target_dir`.
115
+
116
+ `branch` precedence (handled by `_resolve_branch`):
117
+ CLI `--branch X` > GitLab `default_branch` > git's own default.
118
+ """
119
+ if target_dir.exists():
120
+ typer.secho(f" ⚠ '{target_dir.name}' already exists, skipping.", fg="yellow")
121
+ return True
122
+ try:
123
+ access_token = get_access_token()
124
+ url_with_token = inject_token_into_url(url, access_token)
125
+ chosen = _resolve_branch(url, branch)
126
+ branch_display = f"branch '{chosen}'" if chosen else "default branch"
127
+ typer.secho(f" ⇣ Cloning '{target_dir.name}' ({branch_display})…", fg="cyan")
128
+ if chosen:
129
+ Repo.clone_from(url_with_token, target_dir, branch=chosen)
130
+ else:
131
+ Repo.clone_from(url_with_token, target_dir)
132
+ typer.secho(f" ✓ Cloned '{target_dir.name}'", fg="green")
133
+ return True
134
+ except GitCommandError as e:
135
+ stderr = (getattr(e, "stderr", "") or "").strip()
136
+ reason = f"Git error: {e}"
137
+ # Heuristic: if git complained the branch isn't upstream, surface the real cause
138
+ if "Remote branch" in stderr and "not found" in stderr:
139
+ reason = (
140
+ f"The default branch for this repo is not 'main'. "
141
+ f"git said: {stderr.splitlines()[-1]}"
142
+ )
143
+ errors.clone_failed(target_dir.name, reason)
144
+ return False
145
+ except errors.AccessTokenMissingError:
146
+ errors.access_token_missing_rc_fallback()
147
+ return False
148
+ except Exception as e:
149
+ errors.clone_failed(target_dir.name, f"Unexpected error: {e}")
150
+ return False
151
+
152
+
153
+ def clone_module_command(
154
+ url: str = typer.Option(..., "--url", help="Full HTTP URL of the module repo."),
155
+ branch: Optional[str] = None,
156
+ depth: int = typer.Option(1, "--depth", "-d", help="Max dependency depth to clone. 1 clones only the target module, 2 clones target + immediate dependencies, etc."),
157
+ workers: int = typer.Option(4, "--workers", "-w", help="Max concurrent clones (1-8)."),
158
+ ):
159
+ """Clone a module and (optionally) its dependencies into the current directory."""
160
+ try:
161
+ core_modules = get_core_modules_from_config()
162
+ except errors.ConfigError as e:
163
+ errors._safe_exit(e)
164
+
165
+ if not get_access_token_safe():
166
+ typer.secho("")
167
+ typer.secho("┌─ odooflow setup needed", fg="cyan", bold=True)
168
+ typer.secho(
169
+ "│ No GitLab access token found. Run `odooflow setup` to create one",
170
+ fg="cyan",
171
+ )
172
+ typer.secho(
173
+ "│ interactively, or set ODOOFLOW_ACCESS_TOKEN in your shell.",
174
+ fg="cyan",
175
+ )
176
+ typer.secho("")
177
+ typer.secho(
178
+ " Example:\n"
179
+ " export ODOOFLOW_ACCESS_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx",
180
+ fg="cyan",
181
+ )
182
+ typer.secho("")
183
+ raise typer.Exit(code=1)
184
+
185
+ typer.secho("")
186
+ typer.secho("┌─ odooflow clone", fg="cyan", bold=True)
187
+ typer.secho(
188
+ f"│ Depth: {depth} Workers: {max(1, min(workers, 8))}",
189
+ fg="cyan",
190
+ )
191
+ typer.secho("")
192
+
193
+ visited = set()
194
+ fail_count = 0
195
+ lock = threading.Lock()
196
+
197
+ def clone_recursive(module_url: str, current_branch: Optional[str], current_depth: int):
198
+ nonlocal fail_count
199
+ module_name = extract_module_name_from_url(module_url)
200
+
201
+ with lock:
202
+ if module_name in visited:
203
+ typer.secho(f" ↻ Already processed '{module_name}'.", fg="yellow")
204
+ return False
205
+ visited.add(module_name)
206
+
207
+ target_path = Path.cwd() / module_name
208
+
209
+ if not clone_repo(module_url, target_path, current_branch):
210
+ typer.secho(f" ✗ Skipping dependencies of '{module_name}'.", fg="red")
211
+ with lock:
212
+ fail_count += 1
213
+ return False
214
+
215
+ if current_depth <= 0:
216
+ return True
217
+
218
+ manifest_path = target_path / "__manifest__.py"
219
+ if not manifest_path.exists():
220
+ typer.secho(f" · No manifest in '{module_name}'.", fg="yellow")
221
+ return True
222
+
223
+ manifest_data = safe_eval_manifest(manifest_path.read_text())
224
+ dependencies = manifest_data.get("depends", [])
225
+
226
+ if not dependencies:
227
+ typer.secho(f" · '{module_name}' has no dependencies.", fg="cyan")
228
+ return True
229
+
230
+ candidate_deps = [dep for dep in dependencies if dep not in core_modules]
231
+ if not candidate_deps:
232
+ return True
233
+
234
+ next_depth = current_depth - 1
235
+
236
+ def _resolve_and_run(dep_name: str):
237
+ nonlocal fail_count
238
+ dep_url = get_project_url_from_gitlab(module_name=dep_name)
239
+ if not dep_url:
240
+ with lock:
241
+ fail_count += 1
242
+ return
243
+ clone_recursive(dep_url, current_branch, next_depth)
244
+
245
+ typer.secho(
246
+ f" ⇢ Resolving {len(candidate_deps)} dependency(ies) of '{module_name}' in parallel…",
247
+ fg="cyan",
248
+ )
249
+ pool_size = max(1, min(workers, 8))
250
+ with ThreadPoolExecutor(max_workers=pool_size) as executor:
251
+ futures = [executor.submit(_resolve_and_run, dep) for dep in candidate_deps]
252
+ for f in futures:
253
+ f.result()
254
+
255
+ return True
256
+
257
+ clone_recursive(url, branch, depth)
258
+
259
+ typer.secho("")
260
+ typer.secho("└─ odooflow clone finished", fg="cyan", bold=True)
261
+ if fail_count > 0:
262
+ typer.secho(
263
+ f" ✗ {fail_count} module(s) failed to clone. See messages above.",
264
+ fg="red",
265
+ bold=True,
266
+ )
267
+ raise typer.Exit(code=1)
268
+ typer.secho(
269
+ f" ✓ All {len(visited)} module(s) processed without errors.",
270
+ fg="green",
271
+ bold=True,
272
+ )
273
+ typer.secho("")