llamactl 0.3.0a12__py3-none-any.whl → 0.3.0a14__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.
- llama_deploy/cli/__init__.py +3 -2
- llama_deploy/cli/app.py +5 -7
- llama_deploy/cli/client.py +31 -24
- llama_deploy/cli/commands/auth.py +377 -0
- llama_deploy/cli/commands/deployment.py +113 -74
- llama_deploy/cli/commands/env.py +206 -0
- llama_deploy/cli/config/_config.py +385 -0
- llama_deploy/cli/config/auth_service.py +68 -0
- llama_deploy/cli/config/env_service.py +64 -0
- llama_deploy/cli/config/schema.py +31 -0
- llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llama_deploy/cli/interactive_prompts/utils.py +6 -70
- llama_deploy/cli/options.py +15 -1
- llama_deploy/cli/platform_client.py +52 -0
- llama_deploy/cli/textual/deployment_form.py +11 -10
- llama_deploy/cli/textual/deployment_monitor.py +98 -105
- llama_deploy/cli/textual/git_validation.py +11 -9
- llama_deploy/cli/textual/styles.tcss +21 -5
- {llamactl-0.3.0a12.dist-info → llamactl-0.3.0a14.dist-info}/METADATA +3 -3
- llamactl-0.3.0a14.dist-info/RECORD +32 -0
- llama_deploy/cli/commands/profile.py +0 -217
- llama_deploy/cli/config.py +0 -173
- llama_deploy/cli/textual/profile_form.py +0 -170
- llamactl-0.3.0a12.dist-info/RECORD +0 -27
- {llamactl-0.3.0a12.dist-info → llamactl-0.3.0a14.dist-info}/WHEEL +0 -0
- {llamactl-0.3.0a12.dist-info → llamactl-0.3.0a14.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Configuration and profile management for llamactl"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .schema import DEFAULT_ENVIRONMENT, Auth, Environment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _to_auth(row: Any) -> Auth:
|
|
12
|
+
return Auth(
|
|
13
|
+
name=row[0],
|
|
14
|
+
api_url=row[1],
|
|
15
|
+
project_id=row[2],
|
|
16
|
+
api_key=row[3],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_environment(row: Any) -> Environment:
|
|
21
|
+
return Environment(
|
|
22
|
+
api_url=row[0],
|
|
23
|
+
requires_auth=bool(row[1]),
|
|
24
|
+
min_llamactl_version=row[2],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigManager:
|
|
29
|
+
"""Manages profiles and configuration using SQLite"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.config_dir = self._get_config_dir()
|
|
33
|
+
self.db_path = self.config_dir / "profiles.db"
|
|
34
|
+
self._ensure_config_dir()
|
|
35
|
+
self._init_database()
|
|
36
|
+
|
|
37
|
+
def _get_config_dir(self) -> Path:
|
|
38
|
+
"""Get the configuration directory path based on OS.
|
|
39
|
+
|
|
40
|
+
Honors LLAMACTL_CONFIG_DIR when set. This helps tests isolate state.
|
|
41
|
+
"""
|
|
42
|
+
override = os.environ.get("LLAMACTL_CONFIG_DIR")
|
|
43
|
+
if override:
|
|
44
|
+
return Path(override).expanduser()
|
|
45
|
+
if os.name == "nt": # Windows
|
|
46
|
+
config_dir = Path(os.environ.get("APPDATA", "~")) / "llamactl"
|
|
47
|
+
else: # Unix-like (Linux, macOS)
|
|
48
|
+
config_dir = Path.home() / ".config" / "llamactl"
|
|
49
|
+
return config_dir.expanduser()
|
|
50
|
+
|
|
51
|
+
def _ensure_config_dir(self):
|
|
52
|
+
"""Create configuration directory if it doesn't exist"""
|
|
53
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
def _init_database(self):
|
|
56
|
+
"""Initialize SQLite database with required tables"""
|
|
57
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
58
|
+
# Check if we need to migrate from old schema
|
|
59
|
+
cursor = conn.execute("PRAGMA table_info(profiles)")
|
|
60
|
+
columns = [row[1] for row in cursor.fetchall()]
|
|
61
|
+
|
|
62
|
+
# Migration: handle old active_project_id -> project_id and make it required
|
|
63
|
+
if "active_project_id" in columns and "project_id" not in columns:
|
|
64
|
+
# Delete any profiles that have no active_project_id since project_id is now required
|
|
65
|
+
conn.execute(
|
|
66
|
+
"DELETE FROM profiles WHERE active_project_id IS NULL OR active_project_id = ''"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Rename active_project_id to project_id
|
|
70
|
+
# Note: SQLite doesn't allow changing column constraints easily, but we enforce
|
|
71
|
+
# the NOT NULL constraint in our application code and new table creation
|
|
72
|
+
conn.execute(
|
|
73
|
+
"ALTER TABLE profiles RENAME COLUMN active_project_id TO project_id"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Add api_key column if not already present
|
|
77
|
+
mig_cursor = conn.execute("PRAGMA table_info(profiles)")
|
|
78
|
+
mig_columns = [row[1] for row in mig_cursor.fetchall()]
|
|
79
|
+
if "api_key" not in mig_columns:
|
|
80
|
+
conn.execute("ALTER TABLE profiles ADD COLUMN api_key TEXT")
|
|
81
|
+
|
|
82
|
+
# Create tables with new schema (this will only create if they don't exist)
|
|
83
|
+
conn.execute("""
|
|
84
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
85
|
+
name TEXT PRIMARY KEY,
|
|
86
|
+
api_url TEXT NOT NULL,
|
|
87
|
+
project_id TEXT NOT NULL,
|
|
88
|
+
api_key TEXT
|
|
89
|
+
)
|
|
90
|
+
""")
|
|
91
|
+
|
|
92
|
+
conn.execute("""
|
|
93
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
94
|
+
key TEXT PRIMARY KEY,
|
|
95
|
+
value TEXT NOT NULL
|
|
96
|
+
)
|
|
97
|
+
""")
|
|
98
|
+
|
|
99
|
+
# Environments: first-class environments table
|
|
100
|
+
conn.execute(
|
|
101
|
+
"""
|
|
102
|
+
CREATE TABLE IF NOT EXISTS environments (
|
|
103
|
+
api_url TEXT PRIMARY KEY,
|
|
104
|
+
requires_auth INTEGER NOT NULL,
|
|
105
|
+
min_llamactl_version TEXT
|
|
106
|
+
)
|
|
107
|
+
"""
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Ensure there is a current environment setting. If missing, set to default.
|
|
111
|
+
setting_cursor = conn.execute(
|
|
112
|
+
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
113
|
+
)
|
|
114
|
+
setting_row = setting_cursor.fetchone()
|
|
115
|
+
if not setting_row:
|
|
116
|
+
conn.execute(
|
|
117
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
118
|
+
(DEFAULT_ENVIRONMENT.api_url,),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Seed environments from existing profiles if environments is empty
|
|
122
|
+
env_count_cur = conn.execute("SELECT COUNT(*) FROM environments")
|
|
123
|
+
env_count = env_count_cur.fetchone()[0]
|
|
124
|
+
if env_count == 0:
|
|
125
|
+
# Insert distinct api_url values from profiles with requires_auth = 0 (False)
|
|
126
|
+
conn.execute(
|
|
127
|
+
"""
|
|
128
|
+
INSERT OR IGNORE INTO environments (api_url, requires_auth)
|
|
129
|
+
SELECT DISTINCT api_url, 0 FROM profiles
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Also ensure the current environment exists as a row
|
|
134
|
+
cur_env_cursor = conn.execute(
|
|
135
|
+
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
136
|
+
)
|
|
137
|
+
cur_env_row = cur_env_cursor.fetchone()
|
|
138
|
+
if cur_env_row:
|
|
139
|
+
conn.execute(
|
|
140
|
+
"INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
|
|
141
|
+
(
|
|
142
|
+
cur_env_row[0],
|
|
143
|
+
1 if DEFAULT_ENVIRONMENT.requires_auth else 0,
|
|
144
|
+
DEFAULT_ENVIRONMENT.min_llamactl_version,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
conn.commit()
|
|
149
|
+
|
|
150
|
+
def create_profile(
|
|
151
|
+
self,
|
|
152
|
+
name: str,
|
|
153
|
+
api_url: str,
|
|
154
|
+
project_id: str,
|
|
155
|
+
api_key: str | None = None,
|
|
156
|
+
) -> Auth:
|
|
157
|
+
"""Create a new auth profile"""
|
|
158
|
+
if not project_id.strip():
|
|
159
|
+
raise ValueError("Project ID is required")
|
|
160
|
+
profile = Auth(
|
|
161
|
+
name=name,
|
|
162
|
+
api_url=api_url,
|
|
163
|
+
project_id=project_id,
|
|
164
|
+
api_key=api_key,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
168
|
+
try:
|
|
169
|
+
conn.execute(
|
|
170
|
+
"INSERT INTO profiles (name, api_url, project_id, api_key) VALUES (?, ?, ?, ?)",
|
|
171
|
+
(
|
|
172
|
+
profile.name,
|
|
173
|
+
profile.api_url,
|
|
174
|
+
profile.project_id,
|
|
175
|
+
profile.api_key,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
conn.commit()
|
|
179
|
+
except sqlite3.IntegrityError:
|
|
180
|
+
raise ValueError(f"Profile '{name}' already exists")
|
|
181
|
+
|
|
182
|
+
return profile
|
|
183
|
+
|
|
184
|
+
def get_profile(self, name: str, env_url: str) -> Auth | None:
|
|
185
|
+
"""Get a profile by name"""
|
|
186
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
187
|
+
row = conn.execute(
|
|
188
|
+
"SELECT name, api_url, project_id, api_key FROM profiles WHERE name = ? AND api_url = ?",
|
|
189
|
+
(name, env_url),
|
|
190
|
+
).fetchone()
|
|
191
|
+
if row:
|
|
192
|
+
return _to_auth(row)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
def list_profiles(self, env_url: str) -> list[Auth]:
|
|
196
|
+
"""List all profiles"""
|
|
197
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
198
|
+
return [
|
|
199
|
+
_to_auth(row)
|
|
200
|
+
for row in conn.execute(
|
|
201
|
+
"SELECT name, api_url, project_id, api_key FROM profiles WHERE api_url = ? ORDER BY name",
|
|
202
|
+
(env_url,),
|
|
203
|
+
).fetchall()
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
def delete_profile(self, name: str, env_url: str) -> bool:
|
|
207
|
+
"""Delete a profile by name. Returns True if deleted, False if not found."""
|
|
208
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
209
|
+
cursor = conn.execute(
|
|
210
|
+
"DELETE FROM profiles WHERE name = ? AND api_url = ?", (name, env_url)
|
|
211
|
+
)
|
|
212
|
+
conn.commit()
|
|
213
|
+
|
|
214
|
+
# If this was the active profile, clear it
|
|
215
|
+
if self.get_settings_current_profile_name() == name:
|
|
216
|
+
self.set_settings_current_profile(None)
|
|
217
|
+
|
|
218
|
+
return cursor.rowcount > 0
|
|
219
|
+
|
|
220
|
+
def set_settings_current_profile(self, name: str | None):
|
|
221
|
+
"""Set or clear the current active profile.
|
|
222
|
+
|
|
223
|
+
If name is None, the setting is removed.
|
|
224
|
+
"""
|
|
225
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
226
|
+
if name is None:
|
|
227
|
+
conn.execute("DELETE FROM settings WHERE key = 'current_profile'")
|
|
228
|
+
else:
|
|
229
|
+
conn.execute(
|
|
230
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_profile', ?)",
|
|
231
|
+
(name,),
|
|
232
|
+
)
|
|
233
|
+
conn.commit()
|
|
234
|
+
|
|
235
|
+
def get_settings_current_profile_name(self) -> str | None:
|
|
236
|
+
"""Get the name of the current active profile"""
|
|
237
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
238
|
+
cursor = conn.execute(
|
|
239
|
+
"SELECT value FROM settings WHERE key = 'current_profile'"
|
|
240
|
+
)
|
|
241
|
+
row = cursor.fetchone()
|
|
242
|
+
return row[0] if row else None
|
|
243
|
+
|
|
244
|
+
def get_current_profile(self, env_url: str) -> Auth | None:
|
|
245
|
+
"""Get the current active profile"""
|
|
246
|
+
current_name = self.get_settings_current_profile_name()
|
|
247
|
+
if current_name:
|
|
248
|
+
return self.get_profile(current_name, env_url)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def set_project(self, profile_name: str, env_url: str, project_id: str) -> bool:
|
|
252
|
+
"""Set the project for a profile. Returns True if profile exists."""
|
|
253
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
254
|
+
cursor = conn.execute(
|
|
255
|
+
"UPDATE profiles SET project_id = ? WHERE name = ? AND api_url = ?",
|
|
256
|
+
(project_id, profile_name, env_url),
|
|
257
|
+
)
|
|
258
|
+
conn.commit()
|
|
259
|
+
return cursor.rowcount > 0
|
|
260
|
+
|
|
261
|
+
# Environment management APIs
|
|
262
|
+
def create_or_update_environment(
|
|
263
|
+
self, api_url: str, requires_auth: bool, min_llamactl_version: str | None = None
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Create or update an environment row."""
|
|
266
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
267
|
+
conn.execute(
|
|
268
|
+
"INSERT OR REPLACE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
|
|
269
|
+
(api_url, 1 if requires_auth else 0, min_llamactl_version),
|
|
270
|
+
)
|
|
271
|
+
conn.commit()
|
|
272
|
+
|
|
273
|
+
def get_environment(self, api_url: str) -> Environment | None:
|
|
274
|
+
"""Retrieve an environment by URL."""
|
|
275
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
276
|
+
row = conn.execute(
|
|
277
|
+
"SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
|
|
278
|
+
(api_url,),
|
|
279
|
+
).fetchone()
|
|
280
|
+
if row:
|
|
281
|
+
return _to_environment(row)
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def list_environments(self) -> list[Environment]:
|
|
285
|
+
"""List all environments."""
|
|
286
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
287
|
+
envs = [
|
|
288
|
+
_to_environment(row)
|
|
289
|
+
for row in conn.execute(
|
|
290
|
+
"SELECT api_url, requires_auth, min_llamactl_version FROM environments ORDER BY api_url"
|
|
291
|
+
).fetchall()
|
|
292
|
+
]
|
|
293
|
+
if not envs:
|
|
294
|
+
envs = [DEFAULT_ENVIRONMENT]
|
|
295
|
+
return envs
|
|
296
|
+
|
|
297
|
+
def set_settings_current_environment(self, api_url: str) -> None:
|
|
298
|
+
"""Set the current environment by URL.
|
|
299
|
+
|
|
300
|
+
Requires the environment row to already exist (validated elsewhere, e.g. via
|
|
301
|
+
a probe before creation). Raises ValueError if not found.
|
|
302
|
+
"""
|
|
303
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
304
|
+
conn.execute(
|
|
305
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
306
|
+
(api_url,),
|
|
307
|
+
)
|
|
308
|
+
conn.commit()
|
|
309
|
+
|
|
310
|
+
def get_current_environment(self) -> Environment:
|
|
311
|
+
"""Get the current environment.
|
|
312
|
+
|
|
313
|
+
Ensures there is a settings entry and a corresponding row in `environments`.
|
|
314
|
+
"""
|
|
315
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
316
|
+
cursor = conn.execute(
|
|
317
|
+
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
318
|
+
)
|
|
319
|
+
row = cursor.fetchone()
|
|
320
|
+
api_url = row[0] if row else DEFAULT_ENVIRONMENT.api_url
|
|
321
|
+
if not row:
|
|
322
|
+
conn.execute(
|
|
323
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
324
|
+
(api_url,),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Ensure environment exists
|
|
328
|
+
conn.execute(
|
|
329
|
+
"INSERT OR IGNORE INTO environments (api_url, requires_auth, min_llamactl_version) VALUES (?, ?, ?)",
|
|
330
|
+
(
|
|
331
|
+
api_url,
|
|
332
|
+
1 if DEFAULT_ENVIRONMENT.requires_auth else 0,
|
|
333
|
+
DEFAULT_ENVIRONMENT.min_llamactl_version,
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Read the environment
|
|
338
|
+
env_row = conn.execute(
|
|
339
|
+
"SELECT api_url, requires_auth, min_llamactl_version FROM environments WHERE api_url = ?",
|
|
340
|
+
(api_url,),
|
|
341
|
+
).fetchone()
|
|
342
|
+
if env_row:
|
|
343
|
+
return _to_environment(env_row)
|
|
344
|
+
|
|
345
|
+
# If we somehow got here without returning, raise an error
|
|
346
|
+
raise RuntimeError("Failed to load current environment")
|
|
347
|
+
|
|
348
|
+
def delete_environment(self, api_url: str) -> bool:
|
|
349
|
+
"""Delete an environment and all associated profiles.
|
|
350
|
+
|
|
351
|
+
Returns True if the environment existed and was deleted, False otherwise.
|
|
352
|
+
If the deleted environment was current, switch current to the default URL.
|
|
353
|
+
"""
|
|
354
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
355
|
+
# Check existence
|
|
356
|
+
exists_cursor = conn.execute(
|
|
357
|
+
"SELECT 1 FROM environments WHERE api_url = ?",
|
|
358
|
+
(api_url,),
|
|
359
|
+
)
|
|
360
|
+
if exists_cursor.fetchone() is None:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
# Delete profiles tied to this environment
|
|
364
|
+
conn.execute("DELETE FROM profiles WHERE api_url = ?", (api_url,))
|
|
365
|
+
|
|
366
|
+
# Delete environment row
|
|
367
|
+
conn.execute("DELETE FROM environments WHERE api_url = ?", (api_url,))
|
|
368
|
+
|
|
369
|
+
# If current environment is this one, reset to default
|
|
370
|
+
setting_cursor = conn.execute(
|
|
371
|
+
"SELECT value FROM settings WHERE key = 'current_environment_api_url'"
|
|
372
|
+
)
|
|
373
|
+
row = setting_cursor.fetchone()
|
|
374
|
+
if row and row[0] == api_url:
|
|
375
|
+
conn.execute(
|
|
376
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_environment_api_url', ?)",
|
|
377
|
+
(DEFAULT_ENVIRONMENT.api_url,),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
conn.commit()
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Global config manager instance
|
|
385
|
+
config_manager = ConfigManager()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from llama_deploy.cli.config._config import Auth, ConfigManager, Environment
|
|
4
|
+
from llama_deploy.core.client.manage_client import ControlPlaneClient
|
|
5
|
+
from llama_deploy.core.schema import VersionResponse
|
|
6
|
+
from llama_deploy.core.schema.projects import ProjectSummary
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthService:
|
|
10
|
+
def __init__(self, config_manager: ConfigManager, env: Environment):
|
|
11
|
+
self.config_manager = config_manager
|
|
12
|
+
self.env = env
|
|
13
|
+
|
|
14
|
+
def list_profiles(self) -> list[Auth]:
|
|
15
|
+
return self.config_manager.list_profiles(self.env.api_url)
|
|
16
|
+
|
|
17
|
+
def get_profile(self, name: str) -> Auth | None:
|
|
18
|
+
return self.config_manager.get_profile(name, self.env.api_url)
|
|
19
|
+
|
|
20
|
+
def set_current_profile(self, name: str) -> None:
|
|
21
|
+
self.config_manager.set_settings_current_profile(name)
|
|
22
|
+
|
|
23
|
+
def select_any_profile(self) -> None:
|
|
24
|
+
# best effort to select a profile within the environment
|
|
25
|
+
profiles = self.list_profiles()
|
|
26
|
+
if profiles:
|
|
27
|
+
self.set_current_profile(profiles[0].name)
|
|
28
|
+
|
|
29
|
+
def get_current_profile(self) -> Auth | None:
|
|
30
|
+
return self.config_manager.get_current_profile(self.env.api_url)
|
|
31
|
+
|
|
32
|
+
def create_profile_from_token(self, project_id: str, api_key: str | None) -> Auth:
|
|
33
|
+
base = _auto_profile_name_from_token(api_key or "") if api_key else "default"
|
|
34
|
+
auth = self.config_manager.create_profile(
|
|
35
|
+
base, self.env.api_url, project_id, api_key
|
|
36
|
+
)
|
|
37
|
+
self.config_manager.set_settings_current_profile(auth.name)
|
|
38
|
+
return auth
|
|
39
|
+
|
|
40
|
+
def delete_profile(self, name: str) -> bool:
|
|
41
|
+
return self.config_manager.delete_profile(name, self.env.api_url)
|
|
42
|
+
|
|
43
|
+
def set_project(self, name: str, project_id: str) -> None:
|
|
44
|
+
self.config_manager.set_project(name, self.env.api_url, project_id)
|
|
45
|
+
|
|
46
|
+
def fetch_server_version(self) -> VersionResponse:
|
|
47
|
+
async def _fetch_server_version() -> VersionResponse:
|
|
48
|
+
async with ControlPlaneClient.ctx(self.env.api_url) as client:
|
|
49
|
+
version = await client.server_version()
|
|
50
|
+
return version
|
|
51
|
+
|
|
52
|
+
return asyncio.run(_fetch_server_version())
|
|
53
|
+
|
|
54
|
+
def _validate_token_and_list_projects(self, api_key: str) -> list[ProjectSummary]:
|
|
55
|
+
async def _run():
|
|
56
|
+
async with ControlPlaneClient.ctx(self.env.api_url, api_key) as client:
|
|
57
|
+
return await client.list_projects()
|
|
58
|
+
|
|
59
|
+
return asyncio.run(_run())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _auto_profile_name_from_token(api_key: str) -> str:
|
|
63
|
+
token = api_key or "token"
|
|
64
|
+
cleaned = token.replace(" ", "")
|
|
65
|
+
first = cleaned[:6]
|
|
66
|
+
last = cleaned[-4:] if len(cleaned) > 10 else cleaned[-2:]
|
|
67
|
+
base = f"token-{first}-{last}"
|
|
68
|
+
return base
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
|
|
3
|
+
from llama_deploy.cli.config.schema import Environment
|
|
4
|
+
|
|
5
|
+
from ._config import ConfigManager, config_manager
|
|
6
|
+
from .auth_service import AuthService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EnvService:
|
|
10
|
+
def __init__(self, config_manager: ConfigManager):
|
|
11
|
+
self.config_manager = config_manager
|
|
12
|
+
|
|
13
|
+
def list_environments(self) -> list[Environment]:
|
|
14
|
+
return self.config_manager.list_environments()
|
|
15
|
+
|
|
16
|
+
def get_current_environment(self) -> Environment:
|
|
17
|
+
return self.config_manager.get_current_environment()
|
|
18
|
+
|
|
19
|
+
def switch_environment(self, api_url: str) -> Environment:
|
|
20
|
+
env = self.config_manager.get_environment(api_url)
|
|
21
|
+
if not env:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Environment '{api_url}' not found. Add it with 'llamactl auth env add <API_URL>'"
|
|
24
|
+
)
|
|
25
|
+
self.config_manager.set_settings_current_environment(api_url)
|
|
26
|
+
self.config_manager.set_settings_current_profile(None)
|
|
27
|
+
return env
|
|
28
|
+
|
|
29
|
+
def create_or_update_environment(self, env: Environment) -> None:
|
|
30
|
+
self.config_manager.create_or_update_environment(
|
|
31
|
+
env.api_url, env.requires_auth, env.min_llamactl_version
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def delete_environment(self, api_url: str) -> bool:
|
|
35
|
+
return self.config_manager.delete_environment(api_url)
|
|
36
|
+
|
|
37
|
+
def current_auth_service(self) -> AuthService:
|
|
38
|
+
return AuthService(self.config_manager, self.get_current_environment())
|
|
39
|
+
|
|
40
|
+
def auto_update_env(self, env: Environment) -> Environment:
|
|
41
|
+
svc = AuthService(self.config_manager, env)
|
|
42
|
+
version = svc.fetch_server_version()
|
|
43
|
+
update = replace(env)
|
|
44
|
+
update.requires_auth = version.requires_auth
|
|
45
|
+
update.min_llamactl_version = version.min_llamactl_version
|
|
46
|
+
if update != env:
|
|
47
|
+
self.config_manager.create_or_update_environment(
|
|
48
|
+
update.api_url, update.requires_auth, update.min_llamactl_version
|
|
49
|
+
)
|
|
50
|
+
return update
|
|
51
|
+
|
|
52
|
+
def probe_environment(self, api_url: str) -> Environment:
|
|
53
|
+
clean = api_url.rstrip("/")
|
|
54
|
+
base_env = Environment(
|
|
55
|
+
api_url=clean, requires_auth=False, min_llamactl_version=None
|
|
56
|
+
)
|
|
57
|
+
svc = AuthService(self.config_manager, base_env)
|
|
58
|
+
version = svc.fetch_server_version()
|
|
59
|
+
base_env.requires_auth = version.requires_auth
|
|
60
|
+
base_env.min_llamactl_version = version.min_llamactl_version
|
|
61
|
+
return base_env
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
service = EnvService(config_manager)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Auth:
|
|
6
|
+
"""Auth Profile configuration"""
|
|
7
|
+
|
|
8
|
+
name: str
|
|
9
|
+
api_url: str
|
|
10
|
+
project_id: str
|
|
11
|
+
api_key: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Environment:
|
|
16
|
+
"""Environment configuration stored in SQLite.
|
|
17
|
+
|
|
18
|
+
Note: `api_url`, `requires_auth`, and `min_llamactl_version` are persisted
|
|
19
|
+
in the environments table.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
api_url: str
|
|
23
|
+
requires_auth: bool
|
|
24
|
+
min_llamactl_version: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_ENVIRONMENT = Environment(
|
|
28
|
+
api_url="https://api.cloud.llamaindex.ai",
|
|
29
|
+
requires_auth=True,
|
|
30
|
+
min_llamactl_version=None,
|
|
31
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Utilities for detecting and handling interactive CLI sessions."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@functools.cache
|
|
9
|
+
def is_interactive_session() -> bool:
|
|
10
|
+
"""
|
|
11
|
+
Detect if the current CLI session is interactive.
|
|
12
|
+
|
|
13
|
+
Returns True if the session is interactive (user can be prompted),
|
|
14
|
+
False if it's non-interactive (e.g., CI/CD, scripted environment).
|
|
15
|
+
|
|
16
|
+
This function checks multiple indicators:
|
|
17
|
+
- Whether stdin/stdout are connected to a TTY
|
|
18
|
+
- Explicit non-interactive environment variables
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
>>> if is_interactive_session():
|
|
22
|
+
... user_input = questionary.text("Enter value:").ask()
|
|
23
|
+
... else:
|
|
24
|
+
... raise click.ClickException("Value required in non-interactive mode")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Check if stdin and stdout are TTYs
|
|
28
|
+
# This is the most reliable indicator for interactive sessions
|
|
29
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
# Additional check for TERM environment variable
|
|
33
|
+
# Some environments set TERM=dumb for non-interactive sessions
|
|
34
|
+
if os.environ.get("TERM") == "dumb":
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
return True
|
|
@@ -1,84 +1,20 @@
|
|
|
1
1
|
"""Shared utilities for CLI operations"""
|
|
2
2
|
|
|
3
3
|
import questionary
|
|
4
|
-
from rich import print as rprint
|
|
5
4
|
from rich.console import Console
|
|
6
5
|
|
|
7
|
-
from
|
|
8
|
-
from ..config import config_manager
|
|
6
|
+
from .session_utils import is_interactive_session
|
|
9
7
|
|
|
10
8
|
console = Console()
|
|
11
9
|
|
|
12
10
|
|
|
13
|
-
def select_deployment(deployment_id: str | None = None) -> str | None:
|
|
14
|
-
"""
|
|
15
|
-
Select a deployment interactively if ID not provided.
|
|
16
|
-
Returns the selected deployment ID or None if cancelled.
|
|
17
|
-
"""
|
|
18
|
-
if deployment_id:
|
|
19
|
-
return deployment_id
|
|
20
|
-
|
|
21
|
-
try:
|
|
22
|
-
client = get_client()
|
|
23
|
-
deployments = client.list_deployments()
|
|
24
|
-
|
|
25
|
-
if not deployments:
|
|
26
|
-
rprint(
|
|
27
|
-
f"[yellow]No deployments found for project {client.project_id}[/yellow]"
|
|
28
|
-
)
|
|
29
|
-
return None
|
|
30
|
-
|
|
31
|
-
choices = []
|
|
32
|
-
for deployment in deployments:
|
|
33
|
-
name = deployment.name
|
|
34
|
-
deployment_id = deployment.id
|
|
35
|
-
status = deployment.status
|
|
36
|
-
choices.append(
|
|
37
|
-
questionary.Choice(
|
|
38
|
-
title=f"{name} ({deployment_id}) - {status}", value=deployment_id
|
|
39
|
-
)
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
return questionary.select("Select deployment:", choices=choices).ask()
|
|
43
|
-
|
|
44
|
-
except Exception as e:
|
|
45
|
-
rprint(f"[red]Error loading deployments: {e}[/red]")
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def select_profile(profile_name: str | None = None) -> str | None:
|
|
50
|
-
"""
|
|
51
|
-
Select a profile interactively if name not provided.
|
|
52
|
-
Returns the selected profile name or None if cancelled.
|
|
53
|
-
"""
|
|
54
|
-
if profile_name:
|
|
55
|
-
return profile_name
|
|
56
|
-
|
|
57
|
-
try:
|
|
58
|
-
profiles = config_manager.list_profiles()
|
|
59
|
-
|
|
60
|
-
if not profiles:
|
|
61
|
-
rprint("[yellow]No profiles found[/yellow]")
|
|
62
|
-
return None
|
|
63
|
-
|
|
64
|
-
choices = []
|
|
65
|
-
current_name = config_manager.get_current_profile_name()
|
|
66
|
-
|
|
67
|
-
for profile in profiles:
|
|
68
|
-
title = f"{profile.name} ({profile.api_url})"
|
|
69
|
-
if profile.name == current_name:
|
|
70
|
-
title += " [current]"
|
|
71
|
-
choices.append(questionary.Choice(title=title, value=profile.name))
|
|
72
|
-
|
|
73
|
-
return questionary.select("Select profile:", choices=choices).ask()
|
|
74
|
-
|
|
75
|
-
except Exception as e:
|
|
76
|
-
rprint(f"[red]Error loading profiles: {e}[/red]")
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
|
|
80
11
|
def confirm_action(message: str, default: bool = False) -> bool:
|
|
81
12
|
"""
|
|
82
13
|
Ask for confirmation with a consistent interface.
|
|
14
|
+
|
|
15
|
+
In non-interactive sessions, returns the default value without prompting.
|
|
83
16
|
"""
|
|
17
|
+
if not is_interactive_session():
|
|
18
|
+
return default
|
|
19
|
+
|
|
84
20
|
return questionary.confirm(message, default=default).ask() or False
|