ida-hcli 0.7.7.dev2__tar.gz → 0.7.7.dev4__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 (75) hide show
  1. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/PKG-INFO +1 -1
  2. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/pyproject.toml +1 -1
  3. ida_hcli-0.7.7.dev4/src/hcli/__init__.py +1 -0
  4. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/key/create.py +1 -1
  5. ida_hcli-0.7.7.dev4/src/hcli/commands/download.py +233 -0
  6. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/ida/install.py +1 -1
  7. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/auth/__init__.py +40 -1
  8. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/commands/__init__.py +18 -3
  9. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/ida_hcli.egg-info/PKG-INFO +1 -1
  10. ida_hcli-0.7.7.dev2/src/hcli/__init__.py +0 -1
  11. ida_hcli-0.7.7.dev2/src/hcli/commands/download.py +0 -171
  12. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/LICENSE +0 -0
  13. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/README.md +0 -0
  14. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/setup.cfg +0 -0
  15. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/__init__.py +0 -0
  16. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/__init__.py +0 -0
  17. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/default.py +0 -0
  18. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/key/__init__.py +0 -0
  19. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/key/install.py +0 -0
  20. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/key/list.py +0 -0
  21. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/key/revoke.py +0 -0
  22. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/list.py +0 -0
  23. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/auth/switch.py +0 -0
  24. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/commands.py +0 -0
  25. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/common.py +0 -0
  26. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/extension/__init__.py +0 -0
  27. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/extension/create.py +0 -0
  28. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/extension/list.py +0 -0
  29. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/ida/__init__.py +0 -0
  30. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/license/__init__.py +0 -0
  31. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/license/common.py +0 -0
  32. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/license/get.py +0 -0
  33. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/license/install.py +0 -0
  34. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/license/list.py +0 -0
  35. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/login.py +0 -0
  36. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/logout.py +0 -0
  37. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/share/__init__.py +0 -0
  38. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/share/delete.py +0 -0
  39. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/share/get.py +0 -0
  40. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/share/list.py +0 -0
  41. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/share/put.py +0 -0
  42. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/update.py +0 -0
  43. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/commands/whoami.py +0 -0
  44. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/env.py +0 -0
  45. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/__init__.py +0 -0
  46. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/__init__.py +0 -0
  47. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/asset.py +0 -0
  48. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/auth.py +0 -0
  49. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/common.py +0 -0
  50. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/customer.py +0 -0
  51. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/index.py +0 -0
  52. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/keys.py +0 -0
  53. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/api/license.py +0 -0
  54. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/config/__init__.py +0 -0
  55. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/console.py +0 -0
  56. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/constants/__init__.py +0 -0
  57. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/constants/auth.py +0 -0
  58. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/constants/cli.py +0 -0
  59. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/extensions/__init__.py +0 -0
  60. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/ida/__init__.py +0 -0
  61. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/update/__init__.py +0 -0
  62. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/update/release.py +0 -0
  63. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/update/version.py +0 -0
  64. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/util/__init__.py +0 -0
  65. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/util/crc32.py +0 -0
  66. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/util/io.py +0 -0
  67. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/util/output.py +0 -0
  68. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/util/python.py +0 -0
  69. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/lib/util/string.py +0 -0
  70. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/hcli/main.py +0 -0
  71. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/ida_hcli.egg-info/SOURCES.txt +0 -0
  72. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/ida_hcli.egg-info/dependency_links.txt +0 -0
  73. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/ida_hcli.egg-info/entry_points.txt +0 -0
  74. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/ida_hcli.egg-info/requires.txt +0 -0
  75. {ida_hcli-0.7.7.dev2 → ida_hcli-0.7.7.dev4}/src/ida_hcli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ida-hcli
3
- Version: 0.7.7.dev2
3
+ Version: 0.7.7.dev4
4
4
  Summary: HCLI - Hex-Rays CLI Utility
5
5
  Author-email: Hex-Rays SA <support@hex-rays.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ida-hcli"
3
- version = "0.7.7-dev.2"
3
+ version = "0.7.7-dev.4"
4
4
  description = "HCLI - Hex-Rays CLI Utility"
5
5
  requires-python = ">=3.10"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.7-dev.4"
@@ -13,7 +13,7 @@ from hcli.lib.console import console
13
13
 
14
14
 
15
15
  @click.command()
16
- @click.argument("name", required=False)
16
+ @click.option("-n", "--name", help="Name for the new key")
17
17
  @require_auth
18
18
  @async_command
19
19
  async def create(name: str | None) -> None:
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import List, Optional
5
+
6
+ import questionary
7
+ import rich_click as click
8
+ from questionary import Choice
9
+
10
+ from hcli.commands.common import safe_ask_async
11
+ from hcli.lib.api.asset import Asset, TreeNode
12
+ from hcli.lib.api.asset import asset as asset_api
13
+ from hcli.lib.api.common import get_api_client
14
+ from hcli.lib.commands import async_command, auth_command
15
+ from hcli.lib.console import console
16
+ from hcli.lib.constants import cli
17
+
18
+
19
+ class BackNavigationResult:
20
+ """Special result class to indicate backspace navigation."""
21
+
22
+ pass
23
+
24
+
25
+ BACK_NAVIGATION = BackNavigationResult()
26
+
27
+
28
+ async def select_asset(nodes: List[TreeNode], current_path: str = "") -> Optional[Asset]:
29
+ """Alternative traverse using questionary.select with hierarchical navigation."""
30
+
31
+ async def _traverse_recursive(current_nodes: List[TreeNode], path_stack: List[str]) -> Optional[Asset]:
32
+ # Get folders and files at current level
33
+ folders = [node for node in current_nodes if node.type == "folder" and node.children]
34
+ files = [node for node in current_nodes if node.type == "file"]
35
+
36
+ # Build choices using questionary Choice objects
37
+ choices = []
38
+
39
+ # Add "Go back" option if not at root - create a special TreeNode for this
40
+ if path_stack:
41
+ dummy_asset = Asset(filename="", key="")
42
+ back_node = TreeNode(name="← Go back", type="back", asset=dummy_asset)
43
+ choices.append(Choice("← Go back", value=back_node))
44
+
45
+ # Add folders
46
+ for folder in folders:
47
+ choices.append(Choice(f"📁 {folder.name}", value=folder))
48
+
49
+ # Add files
50
+ for file in files:
51
+ # Get display info from asset metadata if available
52
+ display_name = file.name
53
+ if file.asset and file.asset.metadata and "operating_system" in file.asset.metadata:
54
+ display_name = f"{file.asset.metadata['name']} ({file.asset.key.split('/')[-1]})"
55
+ choices.append(Choice(f"📄 {display_name}", value=file))
56
+
57
+ if not choices:
58
+ console.print("[red]No items found at this path[/red]")
59
+ return None
60
+
61
+ # Show current path
62
+ path_display = "/" + "/".join(path_stack) if path_stack else "/"
63
+ console.print(f"[blue]Current path: {path_display}[/blue]")
64
+
65
+ # Get user selection
66
+ selected_node = await safe_ask_async(
67
+ questionary.select(
68
+ "Select an item to navigate or download:",
69
+ choices=choices,
70
+ use_jk_keys=False,
71
+ use_search_filter=True,
72
+ style=cli.SELECT_STYLE,
73
+ )
74
+ )
75
+
76
+ if not selected_node:
77
+ return None
78
+
79
+ # Handle selection based on node type
80
+ if selected_node.type == "back":
81
+ # Go back one level by removing last item from path stack
82
+ return await _traverse_recursive(_get_nodes_at_path(nodes, path_stack[:-1]), path_stack[:-1])
83
+ elif selected_node.type == "folder":
84
+ # Navigate into folder
85
+ if selected_node.children:
86
+ new_path_stack = path_stack + [selected_node.name]
87
+ return await _traverse_recursive(selected_node.children, new_path_stack)
88
+ return None
89
+ elif selected_node.type == "file":
90
+ # File selected - return the asset key
91
+ return selected_node.asset if selected_node.asset else None
92
+
93
+ return None
94
+
95
+ def _get_nodes_at_path(root_nodes: List[TreeNode], path_stack: List[str]) -> List[TreeNode]:
96
+ """Helper to get nodes at a specific path in the tree."""
97
+ current_nodes = root_nodes
98
+ for path_part in path_stack:
99
+ # Find the folder with matching name
100
+ folder = next((node for node in current_nodes if node.type == "folder" and node.name == path_part), None)
101
+ if not folder or not folder.children:
102
+ return []
103
+ current_nodes = folder.children
104
+ return current_nodes
105
+
106
+ return await _traverse_recursive(nodes, [])
107
+
108
+
109
+ def collect_all_assets(nodes: List[TreeNode]) -> List[Asset]:
110
+ """Recursively collect all assets from the tree nodes."""
111
+ assets = []
112
+
113
+ for node in nodes:
114
+ if node.type == "file" and node.asset:
115
+ assets.append(node.asset)
116
+ elif node.type == "folder" and node.children:
117
+ assets.extend(collect_all_assets(node.children))
118
+
119
+ return assets
120
+
121
+
122
+ def filter_assets_by_pattern(assets: List[Asset], pattern: str) -> List[Asset]:
123
+ """Filter assets by regex pattern matching their keys."""
124
+ try:
125
+ compiled_pattern = re.compile(pattern, re.IGNORECASE)
126
+ return [asset for asset in assets if compiled_pattern.search(asset.key)]
127
+ except re.error as e:
128
+ console.print(f"[red]Invalid regex pattern: {e}[/red]")
129
+ return []
130
+
131
+
132
+ def validate_pattern_for_direct_mode(ctx, param, value):
133
+ mode = ctx.params.get('mode')
134
+ if mode == 'direct' and not value:
135
+ raise click.BadParameter('--pattern is required when --mode is "direct"')
136
+ return value
137
+
138
+ @auth_command()
139
+ @click.option("-f", "--force", is_flag=True, help="Skip cache")
140
+ @click.option("--mode", "mode", default="interactive", help="One of interactive or direct")
141
+ @click.option("--output-dir", "output_dir", default="./", help="Output path")
142
+ @click.option("--pattern", "pattern", default=None, help="Pattern to search for assets",callback=validate_pattern_for_direct_mode)
143
+ @click.argument("key", required=False)
144
+ @async_command
145
+ async def download(
146
+ force: bool = False,
147
+ output_dir: str = "./",
148
+ mode: str = "interactive",
149
+ pattern: Optional[str] = None,
150
+ key: Optional[str] = None,
151
+ ) -> None:
152
+ """Download IDA binaries, SDK, utilities and more.
153
+
154
+ KEY: The asset key for direct download eg. release/9.1/ida-pro/ida-pro_91_x64linux.run (optional)
155
+
156
+ \b
157
+ When using direct mode, a pattern is required.
158
+
159
+ """
160
+ try:
161
+ if pattern:
162
+ mode = "direct"
163
+
164
+ if key:
165
+ selected_keys = [key]
166
+ else:
167
+ console.print("[yellow]Fetching available downloads...[/yellow]")
168
+
169
+ if mode == "direct" and pattern:
170
+ # Get downloads from API
171
+ assets = await asset_api.get_files("installers")
172
+
173
+ filtered_assets = filter_assets_by_pattern(assets.items, pattern)
174
+
175
+ if not filtered_assets:
176
+ console.print(f"[red]No assets found matching pattern: {pattern}[/red]")
177
+ return
178
+
179
+ console.print(f"[green]Found {len(filtered_assets)} assets matching pattern:[/green]")
180
+ for asset in filtered_assets:
181
+ console.print(f" • {asset.key}")
182
+
183
+ selected_keys = [asset.key for asset in filtered_assets]
184
+ else:
185
+ # Get downloads from API
186
+ assets = await asset_api.get_files_tree("installers")
187
+
188
+ # Interactive navigation
189
+ selected_asset = await select_asset(assets, "")
190
+
191
+ if not selected_asset:
192
+ console.print("[yellow]Download cancelled[/yellow]")
193
+ return
194
+
195
+ selected_keys = [selected_asset.key]
196
+
197
+ # Download files
198
+ client = await get_api_client()
199
+ downloaded_files = []
200
+
201
+ for selected_key in selected_keys:
202
+ console.print(f"[yellow]Getting download URL for: {selected_key}[/yellow]")
203
+ try:
204
+ download_asset: Optional[Asset] = await asset_api.get_file("installers", selected_key)
205
+ if not download_asset:
206
+ console.print(f"[red]Asset '{selected_key}' not found[/red]")
207
+ continue
208
+ except Exception as e:
209
+ console.print(f"[red]Failed to get download URL for {selected_key}: {e}[/red]")
210
+ continue
211
+
212
+ if not download_asset.url:
213
+ console.print(f"[red]Error: No download URL available for asset {selected_key}[/red]")
214
+ continue
215
+
216
+ # Download the file
217
+ console.print(f"[yellow]Starting download of {selected_key}...[/yellow]")
218
+ try:
219
+ target_path = await client.download_file(download_asset.url, target_dir=output_dir, force=force, auth=True)
220
+ downloaded_files.append(target_path)
221
+ console.print(f"[green]Download complete! File saved to: {target_path}[/green]")
222
+ except Exception as e:
223
+ console.print(f"[red]Failed to download {selected_key}: {e}[/red]")
224
+ continue
225
+
226
+ if downloaded_files:
227
+ console.print(f"[green]Successfully downloaded {len(downloaded_files)} file(s)[/green]")
228
+ else:
229
+ console.print("[red]No files were downloaded[/red]")
230
+
231
+ except Exception as e:
232
+ console.print(f"[red]Download failed: {e}[/red]")
233
+ raise
@@ -46,7 +46,7 @@ async def install(
46
46
  enforce_login()
47
47
 
48
48
  if download_slug:
49
- await download.callback(output_dir=tmp_dir, slug=download_slug)
49
+ await download.callback(output_dir=tmp_dir, key=download_slug)
50
50
  # Find the downloaded installer file
51
51
  installer_path = Path(tmp_dir) / Path(download_slug).name
52
52
  installer = str(installer_path)
@@ -193,8 +193,47 @@ class AuthService:
193
193
  if self._current_source.type == CredentialType.KEY:
194
194
  return bool(self._current_source.token)
195
195
  elif self._current_source.type == CredentialType.INTERACTIVE:
196
- return self.session is not None and self.session.user is not None
196
+ # For interactive auth, check if session is valid by attempting to get user
197
+ if self.session is not None and self.session.user is not None:
198
+ return True
199
+ # If we have credentials but no valid session, try to refresh
200
+ if self._current_source.token:
201
+ try:
202
+ user_response = self.supabase.auth.get_user()
203
+ if user_response.user:
204
+ self.user = user_response.user
205
+ self.session = self.supabase.auth.get_session()
206
+ return True
207
+ except Exception:
208
+ # Token exists but is expired/invalid
209
+ return False
210
+ return False
211
+
212
+ return False
197
213
 
214
+ def has_expired_session(self) -> bool:
215
+ """Check if user has credentials but session is expired."""
216
+ # No expired session for environment API key
217
+ if ENV.HCLI_API_KEY:
218
+ return False
219
+
220
+ # No expired session if no credentials exist
221
+ if not self._current_source:
222
+ return False
223
+
224
+ # Only interactive auth can have expired sessions
225
+ if self._current_source.type != CredentialType.INTERACTIVE:
226
+ return False
227
+
228
+ # Has credentials but session is invalid/expired
229
+ if self._current_source.token and (self.session is None or self.session.user is None):
230
+ try:
231
+ # Try to verify if token is actually expired
232
+ user_response = self.supabase.auth.get_user()
233
+ return user_response.user is None
234
+ except Exception:
235
+ return True
236
+
198
237
  return False
199
238
 
200
239
  def get_auth_type(self) -> Dict[str, str]:
@@ -20,7 +20,12 @@ def require_auth(f: Callable) -> Callable:
20
20
 
21
21
  # Check if user is logged in
22
22
  if not auth_service.is_logged_in():
23
- console.print("[red]You are not logged in. Use 'hcli login'.[/red]")
23
+ if auth_service.has_expired_session():
24
+ current_source = auth_service.get_current_credentials()
25
+ email = current_source.email if current_source else "unknown"
26
+ console.print(f"[red]Your session {email} has expired, use 'hcli login'.[/red]")
27
+ else:
28
+ console.print("[red]You are not logged in. Use 'hcli login'.[/red]")
24
29
  sys.exit(1)
25
30
 
26
31
  return f(*args, **kwargs)
@@ -52,7 +57,12 @@ def enforce_login() -> bool:
52
57
  auth_service = get_auth_service()
53
58
 
54
59
  if not auth_service.is_logged_in():
55
- console.print("[red]You are not logged in. Use 'hcli login'.[/red]")
60
+ if auth_service.has_expired_session():
61
+ current_source = auth_service.get_current_credentials()
62
+ email = current_source.email if current_source else "unknown"
63
+ console.print(f"[red]Your session {email} has expired, use 'hcli login'.[/red]")
64
+ else:
65
+ console.print("[red]You are not logged in. Use 'hcli login'.[/red]")
56
66
  sys.exit(1)
57
67
 
58
68
  return True
@@ -97,7 +107,12 @@ class AuthCommand(BaseCommand):
97
107
 
98
108
  # Check if user is logged in
99
109
  if not auth_service.is_logged_in():
100
- console.print("[red]You are not logged in. Use 'hcli login'.[/red]")
110
+ if auth_service.has_expired_session():
111
+ current_source = auth_service.get_current_credentials()
112
+ email = current_source.email if current_source else "unknown"
113
+ console.print(f"[red]Your session {email} has expired, use 'hcli login'.[/red]")
114
+ else:
115
+ console.print("[red]You are not logged in. Use 'hcli login'.[/red]")
101
116
  sys.exit(1)
102
117
 
103
118
  # Validate forced credentials exists
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ida-hcli
3
- Version: 0.7.7.dev2
3
+ Version: 0.7.7.dev4
4
4
  Summary: HCLI - Hex-Rays CLI Utility
5
5
  Author-email: Hex-Rays SA <support@hex-rays.com>
6
6
  License-Expression: MIT
@@ -1 +0,0 @@
1
- __version__ = "0.7.7-dev.2"
@@ -1,171 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import List, Optional
4
-
5
- import questionary
6
- import rich_click as click
7
- from questionary import Choice
8
-
9
- from hcli.commands.common import safe_ask_async
10
- from hcli.lib.api.asset import Asset, TreeNode
11
- from hcli.lib.api.asset import asset as asset_api
12
- from hcli.lib.api.common import get_api_client
13
- from hcli.lib.commands import async_command, auth_command
14
- from hcli.lib.console import console
15
- from hcli.lib.constants import cli
16
-
17
-
18
- class BackNavigationResult:
19
- """Special result class to indicate backspace navigation."""
20
-
21
- pass
22
-
23
-
24
- BACK_NAVIGATION = BackNavigationResult()
25
-
26
-
27
- async def select_asset(nodes: List[TreeNode], current_path: str = "") -> Optional[Asset]:
28
- """Alternative traverse using questionary.select with hierarchical navigation."""
29
-
30
- async def _traverse_recursive(current_nodes: List[TreeNode], path_stack: List[str]) -> Optional[Asset]:
31
- # Get folders and files at current level
32
- folders = [node for node in current_nodes if node.type == "folder" and node.children]
33
- files = [node for node in current_nodes if node.type == "file"]
34
-
35
- # Build choices using questionary Choice objects
36
- choices = []
37
-
38
- # Add "Go back" option if not at root - create a special TreeNode for this
39
- if path_stack:
40
- dummy_asset = Asset(filename="", key="")
41
- back_node = TreeNode(name="← Go back", type="back", asset=dummy_asset)
42
- choices.append(Choice("← Go back", value=back_node))
43
-
44
- # Add folders
45
- for folder in folders:
46
- choices.append(Choice(f"📁 {folder.name}", value=folder))
47
-
48
- # Add files
49
- for file in files:
50
- # Get display info from asset metadata if available
51
- display_name = file.name
52
- if file.asset and file.asset.metadata and "operating_system" in file.asset.metadata:
53
- display_name = f"{file.asset.metadata['name']} ({file.asset.metadata['operating_system']})"
54
- choices.append(Choice(f"📄 {display_name}", value=file))
55
-
56
- if not choices:
57
- console.print("[red]No items found at this path[/red]")
58
- return None
59
-
60
- # Show current path
61
- path_display = "/" + "/".join(path_stack) if path_stack else "/"
62
- console.print(f"[blue]Current path: {path_display}[/blue]")
63
-
64
- # Get user selection
65
- selected_node = await safe_ask_async(
66
- questionary.select(
67
- "Select an item to navigate or download:",
68
- choices=choices,
69
- use_jk_keys=False,
70
- use_search_filter=True,
71
- style=cli.SELECT_STYLE,
72
- )
73
- )
74
-
75
- if not selected_node:
76
- return None
77
-
78
- # Handle selection based on node type
79
- if selected_node.type == "back":
80
- # Go back one level by removing last item from path stack
81
- return await _traverse_recursive(_get_nodes_at_path(nodes, path_stack[:-1]), path_stack[:-1])
82
- elif selected_node.type == "folder":
83
- # Navigate into folder
84
- if selected_node.children:
85
- new_path_stack = path_stack + [selected_node.name]
86
- return await _traverse_recursive(selected_node.children, new_path_stack)
87
- return None
88
- elif selected_node.type == "file":
89
- # File selected - return the asset key
90
- return selected_node.asset if selected_node.asset else None
91
-
92
- return None
93
-
94
- def _get_nodes_at_path(root_nodes: List[TreeNode], path_stack: List[str]) -> List[TreeNode]:
95
- """Helper to get nodes at a specific path in the tree."""
96
- current_nodes = root_nodes
97
- for path_part in path_stack:
98
- # Find the folder with matching name
99
- folder = next((node for node in current_nodes if node.type == "folder" and node.name == path_part), None)
100
- if not folder or not folder.children:
101
- return []
102
- current_nodes = folder.children
103
- return current_nodes
104
-
105
- return await _traverse_recursive(nodes, [])
106
-
107
-
108
- @auth_command()
109
- @click.option("-f", "--force", is_flag=True, help="Skip cache")
110
- @click.option("--output-dir", "output_dir", default="./", help="Output path")
111
- @click.argument("slug", required=False)
112
- @async_command
113
- async def download(
114
- force: bool = False,
115
- output_dir: str = "./",
116
- version_filter: Optional[str] = None,
117
- latest: bool = False,
118
- category_filter: Optional[str] = None,
119
- slug: Optional[str] = None,
120
- ) -> None:
121
- """Download IDA binaries, SDK, utilities and more."""
122
- try:
123
- selected_key: Optional[str]
124
-
125
- if slug:
126
- selected_key = slug
127
- else:
128
- # Get downloads from API
129
- console.print("[yellow]Fetching available downloads...[/yellow]")
130
- assets = await asset_api.get_files_tree("installers")
131
-
132
- if not assets:
133
- console.print("[red]No downloads available or unable to fetch downloads[/red]")
134
- return
135
-
136
- # Interactive navigation
137
- selected_asset = await select_asset(assets, "")
138
-
139
- if not selected_asset:
140
- console.print("[yellow]Download cancelled[/yellow]")
141
- return
142
-
143
- selected_key = selected_asset.key
144
-
145
- # Get download URL
146
- console.print(f"[yellow]Getting download URL for: {selected_key}[/yellow]")
147
- try:
148
- asset = await asset_api.get_file("installers", selected_key)
149
- except Exception as e:
150
- console.print(f"[red]Failed to get download URL: {e}[/red]")
151
- return
152
-
153
- if not asset:
154
- console.print(f"[red]Asset '{selected_key}' not found[/red]")
155
- return
156
-
157
- # Download the file
158
- console.print("[yellow]Starting download...[/yellow]")
159
- client = await get_api_client()
160
-
161
- if not asset.url:
162
- console.print("[red]Error: No download URL available for asset[/red]")
163
- return
164
-
165
- target_path = await client.download_file(asset.url, target_dir=output_dir, force=force, auth=True)
166
-
167
- console.print(f"[green]Download complete! File saved to: {target_path}[/green]")
168
-
169
- except Exception as e:
170
- console.print(f"[red]Download failed: {e}[/red]")
171
- raise
File without changes
File without changes
File without changes