vuer-cli 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vuer_cli/login.py ADDED
@@ -0,0 +1,459 @@
1
+ """Login command - authenticate with Vuer Hub using OAuth Device Flow."""
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Dict
10
+
11
+ from .utils import print_error
12
+
13
+ # Environment URLs mapping
14
+ ENV_URLS = {
15
+ "dev": "https://staging-auth.vuer.ai",
16
+ "production": "https://auth.vuer.ai", # Update with actual production URL
17
+ }
18
+
19
+
20
+ @dataclass
21
+ class Login:
22
+ """Authenticate with Vuer Hub using OAuth Device Flow.
23
+
24
+ Opens browser for user authentication and saves credentials locally.
25
+ """
26
+
27
+ env: str = "dev" # Environment to authenticate with (dev or production)
28
+
29
+ def __call__(self) -> int:
30
+ """Execute login command."""
31
+ try:
32
+ # Validate environment
33
+ if self.env not in ENV_URLS:
34
+ raise ValueError(
35
+ f"Invalid environment '{self.env}'. Must be one of: {', '.join(ENV_URLS.keys())}"
36
+ )
37
+
38
+ auth_url = ENV_URLS[self.env]
39
+ print(f"[INFO] Authenticating with {self.env} environment...")
40
+ print(f"[INFO] Auth server: {auth_url}")
41
+
42
+ # Generate device secret hash (random string for this session)
43
+ device_secret_hash = secrets.token_urlsafe(16)
44
+
45
+ # Step 1: Start device flow
46
+ print("[INFO] Starting device authentication flow...")
47
+ device_data = start_device_flow(auth_url, device_secret_hash)
48
+
49
+ user_code = device_data["user_code"]
50
+ verification_uri = device_data["verification_uri"]
51
+ verification_uri_complete = device_data.get(
52
+ "verification_uri_complete")
53
+ polling_url = device_data["polling_url"]
54
+ expires_in = device_data["expires_in"]
55
+ interval = device_data.get("interval", 5)
56
+
57
+ # Step 2: Display instructions to user
58
+ print("\n" + "=" * 60)
59
+ print("AUTHENTICATION REQUIRED")
60
+ print("=" * 60)
61
+ print(f"\nPlease visit the following URL to authenticate:")
62
+ print(f"\n {verification_uri}\n")
63
+ print(f"And enter this code: {user_code}")
64
+ print(f"\nThis code will expire in {expires_in} seconds.")
65
+ print("=" * 60 + "\n")
66
+
67
+ # Step 3: Poll for authentication
68
+ print("[INFO] Waiting for authentication...")
69
+ print("[INFO] (Press Ctrl+C to cancel)\n")
70
+
71
+ token_data = poll_for_token(
72
+ polling_url=polling_url,
73
+ device_secret_hash=device_secret_hash,
74
+ interval=interval,
75
+ expires_in=expires_in,
76
+ )
77
+
78
+ access_token = token_data["access_token"]
79
+ refresh_token = token_data.get("refresh_token", "")
80
+ token_type = token_data.get("token_type", "Bearer")
81
+
82
+ # Step 4: Save credentials to environment file
83
+ env_file_path = save_credentials(
84
+ access_token=access_token,
85
+ refresh_token=refresh_token,
86
+ token_type=token_type,
87
+ environment=self.env,
88
+ )
89
+
90
+ # Step 5: Set environment variables globally (platform-specific)
91
+ set_global_env_vars(access_token, refresh_token)
92
+
93
+ # Step 6: Automatically add to shell config (Unix-like systems)
94
+ shell_config_updated = auto_configure_shell(env_file_path)
95
+
96
+ print("\n" + "=" * 60)
97
+ print("AUTHENTICATION SUCCESSFUL")
98
+ print("=" * 60)
99
+ print("\nCredentials have been saved and configured globally.")
100
+ print("\nVuer CLI will automatically use these credentials.")
101
+ print("You can now use other Vuer CLI commands immediately!")
102
+
103
+ if shell_config_updated:
104
+ print(f"\nShell configuration updated: {shell_config_updated}")
105
+ print("Environment variables will be available in new terminal sessions.")
106
+
107
+ print("\n" + "=" * 60 + "\n")
108
+
109
+ return 0
110
+
111
+ except KeyboardInterrupt:
112
+ print("\n\n[INFO] Authentication cancelled by user.")
113
+ return 1
114
+ except ValueError as e:
115
+ print_error(str(e))
116
+ return 1
117
+ except RuntimeError as e:
118
+ print_error(str(e))
119
+ return 1
120
+ except Exception as e:
121
+ print_error(f"Unexpected error during authentication: {e}")
122
+ return 1
123
+
124
+
125
+ def start_device_flow(auth_url: str, device_secret_hash: str) -> Dict[
126
+ str, Any]:
127
+ """Start the device flow and get verification details.
128
+
129
+ Args:
130
+ auth_url: Base URL of the authentication server
131
+ device_secret_hash: Random secret hash for this device session
132
+
133
+ Returns:
134
+ Device flow data including user_code, verification_uri, polling_url, etc.
135
+
136
+ Raises:
137
+ RuntimeError: If the API call fails
138
+ """
139
+ import requests
140
+
141
+ url = f"{auth_url.rstrip('/')}/api/device-flow/start"
142
+ payload = {
143
+ "client_id": "vuer-cli",
144
+ "device_secret_hash": device_secret_hash,
145
+ }
146
+
147
+ try:
148
+ response = requests.post(url, json=payload, timeout=30)
149
+ response.raise_for_status()
150
+ except requests.exceptions.RequestException as e:
151
+ raise RuntimeError(f"Failed to start device flow: {e}") from e
152
+
153
+ data = response.json()
154
+
155
+ # Validate response
156
+ required_fields = ["user_code", "verification_uri", "polling_url",
157
+ "expires_in"]
158
+ missing_fields = [f for f in required_fields if f not in data]
159
+ if missing_fields:
160
+ raise RuntimeError(
161
+ f"Invalid response from auth server: missing fields {missing_fields}"
162
+ )
163
+
164
+ return data
165
+
166
+
167
+ def poll_for_token(
168
+ polling_url: str,
169
+ device_secret_hash: str,
170
+ interval: int,
171
+ expires_in: int,
172
+ ) -> Dict[str, Any]:
173
+ """Poll the authentication server until user completes authentication.
174
+
175
+ Args:
176
+ polling_url: URL to poll for token
177
+ device_secret_hash: Same secret hash used in start_device_flow
178
+ interval: Seconds to wait between polling attempts
179
+ expires_in: Total seconds before the code expires
180
+
181
+ Returns:
182
+ Token data including access_token, refresh_token, token_type
183
+
184
+ Raises:
185
+ RuntimeError: If authentication fails or times out
186
+ """
187
+ import requests
188
+
189
+ payload = {
190
+ "client_id": "vuer-cli",
191
+ "device_secret_hash": device_secret_hash,
192
+ }
193
+
194
+ start_time = time.time()
195
+ attempt = 0
196
+
197
+ while True:
198
+ # Check if expired
199
+ elapsed = time.time() - start_time
200
+ if elapsed >= expires_in:
201
+ raise RuntimeError(
202
+ "Authentication timeout: verification code expired. Please try again."
203
+ )
204
+
205
+ attempt += 1
206
+ dots = "." * (attempt % 4)
207
+ remaining = int(expires_in - elapsed)
208
+ print(
209
+ f"\r[INFO] Waiting for authentication{dots:<3} ({remaining}s remaining)",
210
+ end="",
211
+ flush=True,
212
+ )
213
+
214
+ try:
215
+ response = requests.post(polling_url, json=payload, timeout=30)
216
+
217
+ # Successful authentication
218
+ if response.status_code == 200:
219
+ print("\n") # New line after polling messages
220
+ data = response.json()
221
+
222
+ # Validate response
223
+ if "access_token" not in data:
224
+ raise RuntimeError(
225
+ "Invalid response from auth server: missing access_token"
226
+ )
227
+
228
+ return data
229
+
230
+ # Still waiting for user to authenticate
231
+ elif response.status_code in [202, 428]:
232
+ # 202 = Accepted (still waiting)
233
+ # 428 = Precondition Required (authorization pending)
234
+ time.sleep(interval)
235
+ continue
236
+
237
+ # Check for authorization_pending in error response (some backends use 400)
238
+ elif response.status_code == 400:
239
+ try:
240
+ error_data = response.json()
241
+ error_msg = error_data.get("error", "")
242
+ if error_msg == "authorization_pending":
243
+ # Still waiting for user to complete authentication
244
+ time.sleep(interval)
245
+ continue
246
+ else:
247
+ # Real error
248
+ print("\n")
249
+ raise RuntimeError(
250
+ f"Authentication failed ({response.status_code}): {error_msg}"
251
+ )
252
+ except ValueError:
253
+ # Not JSON, treat as error
254
+ print("\n")
255
+ error_msg = response.text or "Unknown error"
256
+ raise RuntimeError(
257
+ f"Authentication failed ({response.status_code}): {error_msg}"
258
+ )
259
+
260
+ # Authentication denied or other error
261
+ else:
262
+ print("\n") # New line before error
263
+ try:
264
+ error_data = response.json()
265
+ error_msg = error_data.get("error", "Unknown error")
266
+ except Exception:
267
+ error_msg = response.text or "Unknown error"
268
+ raise RuntimeError(
269
+ f"Authentication failed ({response.status_code}): {error_msg}"
270
+ )
271
+
272
+ except requests.exceptions.RequestException as e:
273
+ print("\n") # New line before error
274
+ raise RuntimeError(f"Failed to poll for token: {e}") from e
275
+
276
+
277
+ def save_credentials(
278
+ access_token: str,
279
+ refresh_token: str,
280
+ token_type: str,
281
+ environment: str,
282
+ ) -> str:
283
+ """Save credentials to both JSON config file and shell script.
284
+
285
+ Args:
286
+ access_token: JWT access token
287
+ refresh_token: Refresh token
288
+ token_type: Token type (usually "Bearer")
289
+ environment: Environment name (dev/production)
290
+
291
+ Returns:
292
+ Path to the generated shell environment file
293
+ """
294
+ config_dir = Path.home() / ".vuer"
295
+ config_dir.mkdir(parents=True, exist_ok=True)
296
+
297
+ # Save to JSON file for Python code to read
298
+ credentials_file = config_dir / "credentials"
299
+ credentials = {
300
+ "access_token": access_token,
301
+ "refresh_token": refresh_token,
302
+ "token_type": token_type,
303
+ "environment": environment,
304
+ "updated_at": time.time(),
305
+ }
306
+
307
+ try:
308
+ with credentials_file.open("w", encoding="utf-8") as f:
309
+ json.dump(credentials, f, indent=2)
310
+ credentials_file.chmod(0o600)
311
+ except Exception as e:
312
+ raise RuntimeError(f"Failed to save credentials to JSON: {e}") from e
313
+
314
+ # Save to shell script for users to source
315
+ env_file = config_dir / "env.sh"
316
+ env_content = f"""# Vuer CLI Authentication Configuration
317
+ # Generated on {time.strftime('%Y-%m-%d %H:%M:%S')}
318
+ # Environment: {environment}
319
+
320
+ export VUER_AUTH_TOKEN="{access_token}"
321
+ export REFRESH_TOKEN="{refresh_token}"
322
+ export TOKEN_TYPE="{token_type}"
323
+
324
+ # To use these credentials, run:
325
+ # source ~/.vuer/env.sh
326
+ # Or add this line to your ~/.bashrc or ~/.zshrc:
327
+ # source ~/.vuer/env.sh
328
+ """
329
+
330
+ try:
331
+ with env_file.open("w", encoding="utf-8") as f:
332
+ f.write(env_content)
333
+ env_file.chmod(0o600)
334
+ return str(env_file)
335
+ except Exception as e:
336
+ raise RuntimeError(
337
+ f"Failed to save shell environment file: {e}") from e
338
+
339
+
340
+ def set_global_env_vars(access_token: str, refresh_token: str) -> None:
341
+ """Set environment variables globally based on platform.
342
+
343
+ On Windows: Uses setx to set user-level environment variables
344
+ On macOS/Linux: Sets for current process (will be in shell config too)
345
+
346
+ Args:
347
+ access_token: JWT access token
348
+ refresh_token: Refresh token
349
+ """
350
+ import platform
351
+ import subprocess
352
+
353
+ system = platform.system()
354
+
355
+ if system == "Windows":
356
+ # Use setx to set user-level environment variables on Windows
357
+ try:
358
+ subprocess.run(
359
+ ["setx", "VUER_AUTH_TOKEN", access_token],
360
+ check=False,
361
+ capture_output=True,
362
+ )
363
+ if refresh_token:
364
+ subprocess.run(
365
+ ["setx", "REFRESH_TOKEN", refresh_token],
366
+ check=False,
367
+ capture_output=True,
368
+ )
369
+ except Exception:
370
+ pass # Silently fail if setx not available
371
+ else:
372
+ # On Unix-like systems, set for current process
373
+ # (will also be in shell config via auto_configure_shell)
374
+ os.environ["VUER_AUTH_TOKEN"] = access_token
375
+ if refresh_token:
376
+ os.environ["REFRESH_TOKEN"] = refresh_token
377
+
378
+
379
+ def auto_configure_shell(env_file_path: str) -> str:
380
+ """Automatically add source command to shell configuration file.
381
+
382
+ Args:
383
+ env_file_path: Path to the env.sh file to be sourced
384
+
385
+ Returns:
386
+ Path to the updated shell config file, or empty string if not updated
387
+ """
388
+ import platform
389
+
390
+ # Only configure shell on Unix-like systems
391
+ if platform.system() == "Windows":
392
+ return ""
393
+
394
+ # Detect shell and corresponding config file
395
+ shell = os.environ.get("SHELL", "")
396
+ config_file = None
397
+
398
+ if "zsh" in shell:
399
+ config_file = Path.home() / ".zshrc"
400
+ elif "bash" in shell:
401
+ config_file = Path.home() / ".bashrc"
402
+ # Also check for .bash_profile on macOS
403
+ bash_profile = Path.home() / ".bash_profile"
404
+ if bash_profile.exists():
405
+ config_file = bash_profile
406
+ else:
407
+ # Try to find config file based on what exists
408
+ zshrc = Path.home() / ".zshrc"
409
+ bashrc = Path.home() / ".bashrc"
410
+ bash_profile = Path.home() / ".bash_profile"
411
+
412
+ if zshrc.exists():
413
+ config_file = zshrc
414
+ elif bash_profile.exists():
415
+ config_file = bash_profile
416
+ elif bashrc.exists():
417
+ config_file = bashrc
418
+
419
+ if not config_file:
420
+ return ""
421
+
422
+ source_line = f"source {env_file_path}"
423
+
424
+ try:
425
+ # Check if already configured
426
+ if config_file.exists():
427
+ content = config_file.read_text(encoding="utf-8")
428
+ # Check if the source line already exists
429
+ if source_line in content or f"source ~/.vuer/env.sh" in content:
430
+ return "" # Already configured
431
+
432
+ # Add source line to config file
433
+ with config_file.open("a", encoding="utf-8") as f:
434
+ f.write(f"\n# Vuer CLI authentication (auto-added by vuer login)\n")
435
+ f.write(f"{source_line}\n")
436
+
437
+ return str(config_file)
438
+
439
+ except Exception:
440
+ # If we can't modify the config file, just return empty
441
+ return ""
442
+
443
+
444
+ def load_credentials() -> Dict[str, Any]:
445
+ """Load credentials from local configuration file.
446
+
447
+ Returns:
448
+ Credentials dictionary, or empty dict if file doesn't exist
449
+ """
450
+ credentials_file = Path.home() / ".vuer" / "credentials"
451
+
452
+ if not credentials_file.exists():
453
+ return {}
454
+
455
+ try:
456
+ with credentials_file.open("r", encoding="utf-8") as f:
457
+ return json.load(f)
458
+ except Exception:
459
+ return {}
vuer_cli/main.py CHANGED
@@ -5,6 +5,7 @@ from params_proto import proto
5
5
  from .add import Add
6
6
  from .envs_publish import EnvsPublish
7
7
  from .envs_pull import EnvsPull
8
+ from .login import Login
8
9
  from .remove import Remove
9
10
  from .scripts.demcap import Demcap
10
11
  from .scripts.minimap import Minimap
@@ -21,7 +22,8 @@ def entrypoint() -> int:
21
22
 
22
23
  @proto.cli(prog="vuer")
23
24
  def _cli_entrypoint(
24
- command: Sync
25
+ command: Login
26
+ | Sync
25
27
  | Add
26
28
  | Remove
27
29
  | Upgrade
@@ -35,6 +37,7 @@ def _cli_entrypoint(
35
37
  """Vuer Hub Environment Manager.
36
38
 
37
39
  Available commands:
40
+ login - Authenticate with Vuer Hub using OAuth Device Flow
38
41
  sync - Sync environments from environment.json dependencies (like npm install)
39
42
  add - Add an environment to environment.json and run sync
40
43
  remove - Remove an environment from environment.json and run sync
@@ -47,6 +50,8 @@ def _cli_entrypoint(
47
50
  viz_ptc_proxie - Visualize GLB robot model with point cloud
48
51
 
49
52
  Examples:
53
+ vuer login Authenticate with Vuer Hub (dev environment)
54
+ vuer login --env production Authenticate with production environment
50
55
  vuer sync Sync all dependencies from environment.json
51
56
  vuer add my-env/1.2.3 Add an environment and sync
52
57
  vuer remove my-env/1.2.3 Remove an environment and sync
@@ -62,4 +67,4 @@ def _cli_entrypoint(
62
67
  VUER_HUB_URL - Base URL of the Vuer Hub API
63
68
  VUER_AUTH_TOKEN - JWT token for API authentication
64
69
  """
65
- return command.run()
70
+ return command()
vuer_cli/remove.py CHANGED
@@ -6,92 +6,92 @@ from pathlib import Path
6
6
  from params_proto import proto
7
7
 
8
8
  from .sync import Sync, read_environments_lock
9
- from .utils import print_error, parse_env_spec, normalize_env_spec
9
+ from .utils import normalize_env_spec, parse_env_spec, print_error
10
10
 
11
11
 
12
12
  @proto
13
13
  class Remove:
14
- """Remove an environment from environment.json and run `vuer sync`.
15
-
16
- Example:
17
- vuer remove some-environment/v1.2.3
18
- """
19
-
20
- # Required positional arg: environment spec, e.g. "some-environment/v1.2.3"
21
- env: str
22
-
23
- def run(self) -> int:
24
- """Execute remove command."""
14
+ """Remove an environment from environment.json and run `vuer sync`.
15
+
16
+ Example:
17
+ vuer remove some-environment/v1.2.3
18
+ """
19
+
20
+ # Required positional arg: environment spec, e.g. "some-environment/v1.2.3"
21
+ env: str
22
+
23
+ def __call__(self) -> int:
24
+ """Execute remove command."""
25
+ try:
26
+ env_spec = self.env
27
+
28
+ name, version = parse_env_spec(env_spec)
29
+ env_spec_normalized = normalize_env_spec(f"{name}/{version}")
30
+
31
+ cwd = Path.cwd()
32
+ module_dir = cwd / "vuer_environments"
33
+ lock_path = cwd / "environments-lock.yaml"
34
+
35
+ # Step 2: Ensure vuer_environments/dependencies.toml exists
36
+ if not module_dir.exists() or not lock_path.exists():
37
+ raise FileNotFoundError(
38
+ "vuer_environments directory or environments-lock.yaml not found. "
39
+ "Please run `vuer sync` first to generate environments-lock.yaml."
40
+ )
41
+ existing_deps = read_environments_lock(lock_path)
42
+ if env_spec_normalized not in existing_deps:
43
+ print(f"[INFO] Environment {env_spec_normalized} is not present in {lock_path}")
44
+ return 0
45
+
46
+ # Step 3: Remove from environment.json dependencies, then run sync
47
+ env_json_path = cwd / "environment.json"
48
+ if not env_json_path.exists():
49
+ raise FileNotFoundError("environment.json not found. Cannot remove dependency.")
50
+
51
+ with env_json_path.open("r", encoding="utf-8") as f:
25
52
  try:
26
- env_spec = self.env
27
-
28
- name, version = parse_env_spec(env_spec)
29
- env_spec_normalized = normalize_env_spec(f"{name}/{version}")
30
-
31
- cwd = Path.cwd()
32
- module_dir = cwd / "vuer_environments"
33
- lock_path = cwd / "environments-lock.yaml"
34
-
35
- # Step 2: Ensure vuer_environments/dependencies.toml exists
36
- if not module_dir.exists() or not lock_path.exists():
37
- raise FileNotFoundError(
38
- "vuer_environments directory or environments-lock.yaml not found. "
39
- "Please run `vuer sync` first to generate environments-lock.yaml."
40
- )
41
- existing_deps = read_environments_lock(lock_path)
42
- if env_spec_normalized not in existing_deps:
43
- print(f"[INFO] Environment {env_spec_normalized} is not present in {lock_path}")
44
- return 0
45
-
46
- # Step 3: Remove from environment.json dependencies, then run sync
47
- env_json_path = cwd / "environment.json"
48
- if not env_json_path.exists():
49
- raise FileNotFoundError(
50
- "environment.json not found. Cannot remove dependency."
51
- )
52
-
53
- with env_json_path.open("r", encoding="utf-8") as f:
54
- try:
55
- data = json.load(f)
56
- except json.JSONDecodeError as e:
57
- raise ValueError(
58
- f"Invalid environment.json: {e}"
59
- ) from e
60
-
61
- deps = data.get("dependencies")
62
- if deps is None:
63
- deps = {}
64
- if not isinstance(deps, dict):
65
- raise ValueError(
66
- "environment.json 'dependencies' field must be an object"
67
- )
68
-
69
- # Remove the dependency if present and version matches exactly.
70
- current_version = deps.get(name)
71
- if current_version is None:
72
- print(f"[INFO] Dependency {env_spec_normalized} not found in environment.json. Skipping removal.")
73
- else:
74
- # Only remove if the version in environment.json matches the requested version.
75
- if current_version != version:
76
- print(
77
- f"[INFO] Skipping removal: environment '{name}' is pinned to version "
78
- f"'{current_version}' in environment.json (requested '{version}')."
79
- )
80
- else:
81
- deps.pop(name, None)
82
- print(f"[INFO] Removed {env_spec_normalized} from environment.json dependencies.")
83
-
84
- data["dependencies"] = deps
85
- with env_json_path.open("w", encoding="utf-8") as f:
86
- json.dump(data, f, indent=2, ensure_ascii=False)
87
- f.write("\n")
88
-
89
- print("[INFO] Running sync to reconcile vuer_environments/ with updated dependencies...")
90
- return Sync().run()
91
-
92
- except (FileNotFoundError, ValueError, RuntimeError) as e:
93
- print_error(str(e))
94
- return 1
95
- except Exception as e:
96
- print_error(f"Unexpected error: {e}")
97
- return 1
53
+ data = json.load(f)
54
+ except json.JSONDecodeError as e:
55
+ raise ValueError(f"Invalid environment.json: {e}") from e
56
+
57
+ deps = data.get("dependencies")
58
+ if deps is None:
59
+ deps = {}
60
+ if not isinstance(deps, dict):
61
+ raise ValueError("environment.json 'dependencies' field must be an object")
62
+
63
+ # Remove the dependency if present and version matches exactly.
64
+ current_version = deps.get(name)
65
+ if current_version is None:
66
+ print(
67
+ f"[INFO] Dependency {env_spec_normalized} not found in environment.json. Skipping removal."
68
+ )
69
+ else:
70
+ # Only remove if the version in environment.json matches the requested version.
71
+ if current_version != version:
72
+ print(
73
+ f"[INFO] Skipping removal: environment '{name}' is pinned to version "
74
+ f"'{current_version}' in environment.json (requested '{version}')."
75
+ )
76
+ else:
77
+ deps.pop(name, None)
78
+ print(
79
+ f"[INFO] Removed {env_spec_normalized} from environment.json dependencies."
80
+ )
81
+
82
+ data["dependencies"] = deps
83
+ with env_json_path.open("w", encoding="utf-8") as f:
84
+ json.dump(data, f, indent=2, ensure_ascii=False)
85
+ f.write("\n")
86
+
87
+ print(
88
+ "[INFO] Running sync to reconcile vuer_environments/ with updated dependencies..."
89
+ )
90
+ return Sync().run()
91
+
92
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
93
+ print_error(str(e))
94
+ return 1
95
+ except Exception as e:
96
+ print_error(f"Unexpected error: {e}")
97
+ return 1