clue2app-cli 0.2.7__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.
- clue2app_cli/__init__.py +10 -0
- clue2app_cli/agents/__init__.py +9 -0
- clue2app_cli/agents/ecr_healer.py +475 -0
- clue2app_cli/auth.py +343 -0
- clue2app_cli/config.py +316 -0
- clue2app_cli/core_client.py +46 -0
- clue2app_cli/creds_module.py +253 -0
- clue2app_cli/curses_wizard.py +839 -0
- clue2app_cli/entrypoint_check.py +215 -0
- clue2app_cli/main.py +6711 -0
- clue2app_cli/sdk.py +2993 -0
- clue2app_cli/tui/__init__.py +33 -0
- clue2app_cli/tui/curses_app.py +733 -0
- clue2app_cli/tui/data.py +316 -0
- clue2app_cli/tui/screens/__init__.py +1 -0
- clue2app_cli/tui/textual_app.py +1021 -0
- clue2app_cli/tui/widgets/__init__.py +1 -0
- clue2app_cli/tui_wizard.py +987 -0
- clue2app_cli/version.py +3 -0
- clue2app_cli/wizard.py +1079 -0
- clue2app_cli-0.2.7.dist-info/METADATA +202 -0
- clue2app_cli-0.2.7.dist-info/RECORD +26 -0
- clue2app_cli-0.2.7.dist-info/WHEEL +5 -0
- clue2app_cli-0.2.7.dist-info/entry_points.txt +3 -0
- clue2app_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
- clue2app_cli-0.2.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Textual-based Deploy Wizard with CSS animations for Clue2App CLI.
|
|
4
|
+
|
|
5
|
+
This module provides a rich, animated TUI wizard experience using Textual,
|
|
6
|
+
with smooth transitions, CSS styling, and reactive components.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
14
|
+
|
|
15
|
+
from textual import on, work
|
|
16
|
+
from textual.app import App, ComposeResult
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
|
19
|
+
from textual.css.query import NoMatches
|
|
20
|
+
from textual.reactive import reactive
|
|
21
|
+
from textual.screen import Screen
|
|
22
|
+
from textual.widgets import (
|
|
23
|
+
Button, Footer, Header, Input, Label, Static,
|
|
24
|
+
Select, Switch, ProgressBar, Rule, LoadingIndicator,
|
|
25
|
+
Checkbox, RadioSet, RadioButton, TabbedContent, TabPane,
|
|
26
|
+
)
|
|
27
|
+
from textual.timer import Timer
|
|
28
|
+
|
|
29
|
+
from rich.text import Text
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ============================================================================
|
|
35
|
+
# CSS Styles
|
|
36
|
+
# ============================================================================
|
|
37
|
+
|
|
38
|
+
WIZARD_CSS = """
|
|
39
|
+
Screen {
|
|
40
|
+
background: $surface;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Step Indicator Styles */
|
|
44
|
+
#step-indicator-container {
|
|
45
|
+
height: auto;
|
|
46
|
+
padding: 0;
|
|
47
|
+
margin: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#step-indicator {
|
|
51
|
+
height: 3;
|
|
52
|
+
width: 100%;
|
|
53
|
+
background: $panel;
|
|
54
|
+
border: solid $primary;
|
|
55
|
+
padding: 0 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.step-container {
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 3;
|
|
61
|
+
align: center middle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.step {
|
|
65
|
+
width: auto;
|
|
66
|
+
height: 3;
|
|
67
|
+
padding: 0 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.step-circle {
|
|
71
|
+
width: 3;
|
|
72
|
+
height: 3;
|
|
73
|
+
content-align: center middle;
|
|
74
|
+
border: solid $secondary;
|
|
75
|
+
text-style: bold;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.step-circle.active {
|
|
79
|
+
background: $primary;
|
|
80
|
+
border: solid $primary;
|
|
81
|
+
color: $text;
|
|
82
|
+
text-style: bold;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.step-circle.completed {
|
|
86
|
+
background: $success;
|
|
87
|
+
border: solid $success;
|
|
88
|
+
color: $text;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.step-label {
|
|
92
|
+
padding: 0 1;
|
|
93
|
+
color: $text-muted;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.step-label.active {
|
|
97
|
+
color: $primary;
|
|
98
|
+
text-style: bold;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.step-label.completed {
|
|
102
|
+
color: $success;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.step-connector {
|
|
106
|
+
width: 5;
|
|
107
|
+
height: 1;
|
|
108
|
+
margin-top: 1;
|
|
109
|
+
background: $secondary;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.step-connector.completed {
|
|
113
|
+
background: $success;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Content Area */
|
|
117
|
+
#content-area {
|
|
118
|
+
height: 1fr;
|
|
119
|
+
padding: 0 1;
|
|
120
|
+
background: $surface;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.wizard-step {
|
|
124
|
+
width: 100%;
|
|
125
|
+
height: auto;
|
|
126
|
+
min-height: 10;
|
|
127
|
+
padding: 1 2;
|
|
128
|
+
background: $panel;
|
|
129
|
+
border: solid $primary-lighten-2;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.step-title {
|
|
133
|
+
text-style: bold;
|
|
134
|
+
color: $primary;
|
|
135
|
+
padding-bottom: 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.step-description {
|
|
139
|
+
color: $text-muted;
|
|
140
|
+
padding-bottom: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.form-group {
|
|
144
|
+
margin-bottom: 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.form-label {
|
|
148
|
+
padding-bottom: 0;
|
|
149
|
+
color: $text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.form-input {
|
|
153
|
+
margin-bottom: 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* Footer Actions */
|
|
157
|
+
#footer-actions {
|
|
158
|
+
height: 5;
|
|
159
|
+
padding: 1 2;
|
|
160
|
+
background: $panel;
|
|
161
|
+
border-top: solid $primary-lighten-2;
|
|
162
|
+
align: center middle;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#footer-actions Button {
|
|
166
|
+
margin: 0 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.btn-primary {
|
|
170
|
+
background: $primary;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.btn-secondary {
|
|
174
|
+
background: $secondary;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.btn-success {
|
|
178
|
+
background: $success;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* Build Progress */
|
|
182
|
+
#build-progress {
|
|
183
|
+
height: 100%;
|
|
184
|
+
padding: 2;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.progress-header {
|
|
188
|
+
text-style: bold;
|
|
189
|
+
color: $primary;
|
|
190
|
+
padding-bottom: 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.phase-item {
|
|
194
|
+
padding: 0 1;
|
|
195
|
+
margin-bottom: 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.phase-icon {
|
|
199
|
+
width: 3;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.phase-icon.pending {
|
|
203
|
+
color: $text-muted;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.phase-icon.running {
|
|
207
|
+
color: $primary;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.phase-icon.completed {
|
|
211
|
+
color: $success;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.phase-name {
|
|
215
|
+
padding-left: 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.log-container {
|
|
219
|
+
height: 8;
|
|
220
|
+
border: solid $secondary;
|
|
221
|
+
padding: 1;
|
|
222
|
+
margin-top: 1;
|
|
223
|
+
background: $surface-darken-1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Animations - Textual transitions */
|
|
227
|
+
.fade-in {
|
|
228
|
+
opacity: 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.hidden {
|
|
232
|
+
display: none;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* Review Table */
|
|
236
|
+
.review-section {
|
|
237
|
+
margin-bottom: 1;
|
|
238
|
+
padding: 1;
|
|
239
|
+
background: $surface;
|
|
240
|
+
border: solid $secondary;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.review-title {
|
|
244
|
+
text-style: bold;
|
|
245
|
+
color: $primary;
|
|
246
|
+
padding-bottom: 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.review-row {
|
|
250
|
+
padding: 0 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.review-label {
|
|
254
|
+
width: 20;
|
|
255
|
+
color: $text-muted;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.review-value {
|
|
259
|
+
color: $text;
|
|
260
|
+
}
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ============================================================================
|
|
265
|
+
# Step Indicator Widget
|
|
266
|
+
# ============================================================================
|
|
267
|
+
|
|
268
|
+
class StepIndicator(Static):
|
|
269
|
+
"""Animated step indicator showing wizard progress."""
|
|
270
|
+
|
|
271
|
+
STEPS = ["Basics", "Source", "Registry", "Settings", "Review"]
|
|
272
|
+
|
|
273
|
+
current_step = reactive(0)
|
|
274
|
+
completed_steps = reactive(set())
|
|
275
|
+
|
|
276
|
+
def __init__(self, **kwargs):
|
|
277
|
+
super().__init__(**kwargs)
|
|
278
|
+
self.completed_steps = set()
|
|
279
|
+
|
|
280
|
+
def render(self) -> Text:
|
|
281
|
+
"""Render the step indicator."""
|
|
282
|
+
text = Text()
|
|
283
|
+
|
|
284
|
+
for i, step_name in enumerate(self.STEPS):
|
|
285
|
+
# Determine state
|
|
286
|
+
if i in self.completed_steps:
|
|
287
|
+
icon = "✓"
|
|
288
|
+
icon_style = "bold green"
|
|
289
|
+
label_style = "green"
|
|
290
|
+
elif i == self.current_step:
|
|
291
|
+
icon = "●"
|
|
292
|
+
icon_style = "bold blue"
|
|
293
|
+
label_style = "bold blue"
|
|
294
|
+
else:
|
|
295
|
+
icon = "○"
|
|
296
|
+
icon_style = "dim"
|
|
297
|
+
label_style = "dim"
|
|
298
|
+
|
|
299
|
+
text.append(f" {icon} ", style=icon_style)
|
|
300
|
+
text.append(step_name, style=label_style)
|
|
301
|
+
|
|
302
|
+
# Add connector
|
|
303
|
+
if i < len(self.STEPS) - 1:
|
|
304
|
+
connector_style = "green" if i in self.completed_steps else "dim"
|
|
305
|
+
text.append(" ─── ", style=connector_style)
|
|
306
|
+
|
|
307
|
+
return text
|
|
308
|
+
|
|
309
|
+
def set_step(self, step: int):
|
|
310
|
+
"""Set the current step."""
|
|
311
|
+
self.current_step = step
|
|
312
|
+
self.refresh()
|
|
313
|
+
|
|
314
|
+
def complete_step(self, step: int):
|
|
315
|
+
"""Mark a step as completed."""
|
|
316
|
+
self.completed_steps = self.completed_steps | {step}
|
|
317
|
+
self.refresh()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ============================================================================
|
|
321
|
+
# Form Widgets
|
|
322
|
+
# ============================================================================
|
|
323
|
+
|
|
324
|
+
class FormInput(Vertical):
|
|
325
|
+
"""A styled form input with label."""
|
|
326
|
+
|
|
327
|
+
def __init__(self, label: str, input_id: str, placeholder: str = "", password: bool = False, **kwargs):
|
|
328
|
+
super().__init__(**kwargs)
|
|
329
|
+
self.label_text = label
|
|
330
|
+
self.input_id = input_id
|
|
331
|
+
self.placeholder = placeholder
|
|
332
|
+
self.password = password
|
|
333
|
+
|
|
334
|
+
def compose(self) -> ComposeResult:
|
|
335
|
+
yield Label(self.label_text, classes="form-label")
|
|
336
|
+
yield Input(
|
|
337
|
+
placeholder=self.placeholder,
|
|
338
|
+
password=self.password,
|
|
339
|
+
id=self.input_id,
|
|
340
|
+
classes="form-input",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class FormSelect(Vertical):
|
|
345
|
+
"""A styled form select with label."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, label: str, select_id: str, options: List[tuple], **kwargs):
|
|
348
|
+
super().__init__(**kwargs)
|
|
349
|
+
self.label_text = label
|
|
350
|
+
self.select_id = select_id
|
|
351
|
+
self.options = options
|
|
352
|
+
|
|
353
|
+
def compose(self) -> ComposeResult:
|
|
354
|
+
yield Label(self.label_text, classes="form-label")
|
|
355
|
+
yield Select(
|
|
356
|
+
options=self.options,
|
|
357
|
+
id=self.select_id,
|
|
358
|
+
classes="form-input",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ============================================================================
|
|
363
|
+
# Wizard Steps
|
|
364
|
+
# ============================================================================
|
|
365
|
+
|
|
366
|
+
class BasicsStep(Vertical):
|
|
367
|
+
"""Step 1: Application basics."""
|
|
368
|
+
|
|
369
|
+
def __init__(self, projects: List[Dict] = None, **kwargs):
|
|
370
|
+
super().__init__(**kwargs)
|
|
371
|
+
self.projects = projects or []
|
|
372
|
+
|
|
373
|
+
def compose(self) -> ComposeResult:
|
|
374
|
+
yield Static("📱 Application Basics", classes="step-title")
|
|
375
|
+
yield Static("Give your application a name and select target project", classes="step-description")
|
|
376
|
+
yield Rule()
|
|
377
|
+
yield FormInput(
|
|
378
|
+
"Application Name *",
|
|
379
|
+
"input-app-name",
|
|
380
|
+
placeholder="my-awesome-app",
|
|
381
|
+
)
|
|
382
|
+
# Project selection (if multiple projects available)
|
|
383
|
+
if len(self.projects) > 1:
|
|
384
|
+
project_options = [
|
|
385
|
+
(f"{p.get('name')} ({p.get('ns', 'default')})", p.get('id'))
|
|
386
|
+
for p in self.projects
|
|
387
|
+
]
|
|
388
|
+
yield FormSelect("Target Project", "select-project", options=project_options)
|
|
389
|
+
|
|
390
|
+
def get_data(self) -> Dict[str, Any]:
|
|
391
|
+
data = {}
|
|
392
|
+
try:
|
|
393
|
+
app_name_input = self.query_one("#input-app-name", Input)
|
|
394
|
+
data["app_name"] = app_name_input.value
|
|
395
|
+
# Get selected project if available
|
|
396
|
+
try:
|
|
397
|
+
project_select = self.query_one("#select-project", Select)
|
|
398
|
+
if project_select.value:
|
|
399
|
+
data["project_id"] = project_select.value
|
|
400
|
+
except NoMatches:
|
|
401
|
+
pass
|
|
402
|
+
except NoMatches:
|
|
403
|
+
pass
|
|
404
|
+
return data
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class SourceStep(Vertical):
|
|
408
|
+
"""Step 2: Source code configuration."""
|
|
409
|
+
|
|
410
|
+
def compose(self) -> ComposeResult:
|
|
411
|
+
yield Static("📦 Source Code", classes="step-title")
|
|
412
|
+
yield Static("Configure your Git repository", classes="step-description")
|
|
413
|
+
yield Rule()
|
|
414
|
+
yield FormInput(
|
|
415
|
+
"Git Repository URL *",
|
|
416
|
+
"input-git-url",
|
|
417
|
+
placeholder="https://github.com/user/repo.git",
|
|
418
|
+
)
|
|
419
|
+
yield FormSelect(
|
|
420
|
+
"Branch",
|
|
421
|
+
"select-branch",
|
|
422
|
+
options=[("main", "main"), ("master", "master"), ("develop", "develop")],
|
|
423
|
+
)
|
|
424
|
+
with Horizontal():
|
|
425
|
+
yield Checkbox("Private Repository", id="private_repo")
|
|
426
|
+
yield Container(
|
|
427
|
+
FormInput("Git Token", "input-git-token", placeholder="ghp_...", password=True),
|
|
428
|
+
id="git-token-container",
|
|
429
|
+
classes="hidden",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
@on(Checkbox.Changed, "#private_repo")
|
|
433
|
+
def toggle_token_field(self, event: Checkbox.Changed):
|
|
434
|
+
"""Show/hide token field based on private repo checkbox."""
|
|
435
|
+
container = self.query_one("#git-token-container")
|
|
436
|
+
if event.value:
|
|
437
|
+
container.remove_class("hidden")
|
|
438
|
+
else:
|
|
439
|
+
container.add_class("hidden")
|
|
440
|
+
|
|
441
|
+
def get_data(self) -> Dict[str, Any]:
|
|
442
|
+
data = {}
|
|
443
|
+
try:
|
|
444
|
+
data["git_url"] = self.query_one("#input-git-url", Input).value
|
|
445
|
+
data["branch"] = self.query_one("#select-branch", Select).value or "main"
|
|
446
|
+
data["is_private"] = self.query_one("#private_repo", Checkbox).value
|
|
447
|
+
if data["is_private"]:
|
|
448
|
+
data["git_token"] = self.query_one("#input-git-token", Input).value
|
|
449
|
+
except NoMatches:
|
|
450
|
+
pass
|
|
451
|
+
return data
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class RegistryStep(Vertical):
|
|
455
|
+
"""Step 3: Container registry configuration."""
|
|
456
|
+
|
|
457
|
+
def compose(self) -> ComposeResult:
|
|
458
|
+
yield Static("🐳 Container Registry", classes="step-title")
|
|
459
|
+
yield Static("Choose where to store your container images", classes="step-description")
|
|
460
|
+
yield Rule()
|
|
461
|
+
yield RadioSet(
|
|
462
|
+
RadioButton("Platform Registry (Recommended)", id="platform_registry", value=True),
|
|
463
|
+
RadioButton("Private Registry", id="private_registry"),
|
|
464
|
+
id="registry_type",
|
|
465
|
+
)
|
|
466
|
+
yield Container(
|
|
467
|
+
FormInput("Registry URL", "input-registry-url", placeholder="docker.io"),
|
|
468
|
+
FormInput("Username", "input-registry-user", placeholder="username"),
|
|
469
|
+
FormInput("Password", "input-registry-pass", password=True),
|
|
470
|
+
id="private-registry-fields",
|
|
471
|
+
classes="hidden",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
@on(RadioSet.Changed, "#registry_type")
|
|
475
|
+
def toggle_registry_fields(self, event: RadioSet.Changed):
|
|
476
|
+
"""Show/hide private registry fields."""
|
|
477
|
+
container = self.query_one("#private-registry-fields")
|
|
478
|
+
if event.pressed and event.pressed.id == "private_registry":
|
|
479
|
+
container.remove_class("hidden")
|
|
480
|
+
else:
|
|
481
|
+
container.add_class("hidden")
|
|
482
|
+
|
|
483
|
+
def get_data(self) -> Dict[str, Any]:
|
|
484
|
+
data = {}
|
|
485
|
+
try:
|
|
486
|
+
radio_set = self.query_one("#registry_type", RadioSet)
|
|
487
|
+
pressed = radio_set.pressed_button
|
|
488
|
+
if pressed and pressed.id == "private_registry":
|
|
489
|
+
data["registry_type"] = "PRIVATE_REGISTRY"
|
|
490
|
+
data["registry_url"] = self.query_one("#input-registry-url", Input).value
|
|
491
|
+
data["registry_user"] = self.query_one("#input-registry-user", Input).value
|
|
492
|
+
data["registry_pass"] = self.query_one("#input-registry-pass", Input).value
|
|
493
|
+
else:
|
|
494
|
+
data["registry_type"] = "PLATFORM_REGISTRY"
|
|
495
|
+
except (NoMatches, AttributeError):
|
|
496
|
+
data["registry_type"] = "PLATFORM_REGISTRY"
|
|
497
|
+
return data
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class SettingsStep(Vertical):
|
|
501
|
+
"""Step 4: Application settings."""
|
|
502
|
+
|
|
503
|
+
def compose(self) -> ComposeResult:
|
|
504
|
+
yield Static("⚙️ Application Settings", classes="step-title")
|
|
505
|
+
yield Static("Configure deployment, scaling, and environment", classes="step-description")
|
|
506
|
+
yield Rule()
|
|
507
|
+
yield FormInput("Container Port *", "input-port", placeholder="8080")
|
|
508
|
+
yield FormSelect(
|
|
509
|
+
"Application Size",
|
|
510
|
+
"select-size",
|
|
511
|
+
options=[
|
|
512
|
+
("TINY", "Tiny (256MB, 0.25 CPU)"),
|
|
513
|
+
("SMALL", "Small (512MB, 0.5 CPU)"),
|
|
514
|
+
("MEDIUM", "Medium (1GB, 1 CPU)"),
|
|
515
|
+
("LARGE", "Large (2GB, 2 CPU)"),
|
|
516
|
+
],
|
|
517
|
+
)
|
|
518
|
+
with Horizontal():
|
|
519
|
+
yield Checkbox("Deploy Automatically", id="auto_deploy", value=True)
|
|
520
|
+
yield Checkbox("Scale to Zero", id="scale_to_zero")
|
|
521
|
+
|
|
522
|
+
def get_data(self) -> Dict[str, Any]:
|
|
523
|
+
data = {}
|
|
524
|
+
try:
|
|
525
|
+
data["port"] = int(self.query_one("#input-port", Input).value or "8080")
|
|
526
|
+
data["size"] = self.query_one("#select-size", Select).value or "SMALL"
|
|
527
|
+
data["auto_deploy"] = self.query_one("#auto_deploy", Checkbox).value
|
|
528
|
+
data["scale_to_zero"] = self.query_one("#scale_to_zero", Checkbox).value
|
|
529
|
+
except (NoMatches, ValueError):
|
|
530
|
+
data["port"] = 8080
|
|
531
|
+
data["size"] = "SMALL"
|
|
532
|
+
data["auto_deploy"] = True
|
|
533
|
+
data["scale_to_zero"] = False
|
|
534
|
+
return data
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class ReviewStep(Vertical):
|
|
538
|
+
"""Step 5: Review and deploy."""
|
|
539
|
+
|
|
540
|
+
def __init__(self, wizard_data: Dict[str, Any], **kwargs):
|
|
541
|
+
super().__init__(**kwargs)
|
|
542
|
+
self.wizard_data = wizard_data
|
|
543
|
+
|
|
544
|
+
def compose(self) -> ComposeResult:
|
|
545
|
+
yield Static("🚀 Review & Deploy", classes="step-title")
|
|
546
|
+
yield Static("Review your configuration before deploying", classes="step-description")
|
|
547
|
+
yield Rule()
|
|
548
|
+
|
|
549
|
+
# Basic Info
|
|
550
|
+
yield Static("[bold]BASIC INFO[/bold]", classes="review-title")
|
|
551
|
+
yield Static(f" Application Name: {self.wizard_data.get('app_name', 'N/A')}")
|
|
552
|
+
if self.wizard_data.get('project_id'):
|
|
553
|
+
yield Static(f" Project: {self.wizard_data.get('project_id')}")
|
|
554
|
+
|
|
555
|
+
# Source Code
|
|
556
|
+
yield Static("\n[bold]SOURCE CODE[/bold]", classes="review-title")
|
|
557
|
+
yield Static(f" Git Repository: {self.wizard_data.get('git_url', 'N/A')}")
|
|
558
|
+
yield Static(f" Branch: {self.wizard_data.get('branch', 'main')}")
|
|
559
|
+
yield Static(f" Access: {'Private' if self.wizard_data.get('is_private') else 'Public'}")
|
|
560
|
+
|
|
561
|
+
# Registry
|
|
562
|
+
yield Static("\n[bold]CONTAINER REGISTRY[/bold]", classes="review-title")
|
|
563
|
+
registry = self.wizard_data.get('registry_type', 'PLATFORM_REGISTRY')
|
|
564
|
+
if registry == "PLATFORM_REGISTRY":
|
|
565
|
+
yield Static(" Registry: Platform Registry")
|
|
566
|
+
else:
|
|
567
|
+
yield Static(f" Registry: {self.wizard_data.get('registry_url', 'N/A')}")
|
|
568
|
+
|
|
569
|
+
# Settings
|
|
570
|
+
yield Static("\n[bold]SETTINGS[/bold]", classes="review-title")
|
|
571
|
+
yield Static(f" Container Port: {self.wizard_data.get('port', 8080)}")
|
|
572
|
+
yield Static(f" Application Size: {self.wizard_data.get('size', 'SMALL')}")
|
|
573
|
+
yield Static(f" Auto Deploy: {'Yes' if self.wizard_data.get('auto_deploy', True) else 'No'}")
|
|
574
|
+
yield Static(f" Scale to Zero: {'Yes' if self.wizard_data.get('scale_to_zero') else 'No'}")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ============================================================================
|
|
578
|
+
# Build Progress Screen
|
|
579
|
+
# ============================================================================
|
|
580
|
+
|
|
581
|
+
class BuildProgressScreen(Screen):
|
|
582
|
+
"""Screen showing live build progress."""
|
|
583
|
+
|
|
584
|
+
BINDINGS = [
|
|
585
|
+
Binding("escape", "cancel", "Run in Background"),
|
|
586
|
+
Binding("enter", "exit_screen", "Exit", show=False),
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
def __init__(self, app_name: str, namespace: str, sdk_client=None, **kwargs):
|
|
590
|
+
super().__init__(**kwargs)
|
|
591
|
+
self.app_name = app_name
|
|
592
|
+
self.namespace = namespace
|
|
593
|
+
self.sdk_client = sdk_client
|
|
594
|
+
self.current_phase = 0
|
|
595
|
+
self.service_phase = 0
|
|
596
|
+
self.build_status = "BUILDING" # BUILDING, DEPLOYING, SUCCESS, FAILED
|
|
597
|
+
self.app_url: str = ""
|
|
598
|
+
self.error_message: str = ""
|
|
599
|
+
self.build_phases = [
|
|
600
|
+
("FETCH", "Fetching source code"),
|
|
601
|
+
("DETECT", "Detecting buildpacks"),
|
|
602
|
+
("ANALYZE", "Analyzing dependencies"),
|
|
603
|
+
("BUILD", "Building application"),
|
|
604
|
+
("EXPORT", "Exporting container image"),
|
|
605
|
+
]
|
|
606
|
+
self.service_phases = [
|
|
607
|
+
("PENDING", "Service pending"),
|
|
608
|
+
("CREATING", "Creating pods"),
|
|
609
|
+
("CONFIGURING", "Configuring networking"),
|
|
610
|
+
("SCALING", "Scaling replicas"),
|
|
611
|
+
("READY", "Service ready"),
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
def compose(self) -> ComposeResult:
|
|
615
|
+
yield Header(show_clock=True)
|
|
616
|
+
with Container(id="build-progress"):
|
|
617
|
+
yield Static(f"🚀 Deploying: {self.app_name}", classes="progress-header", id="main-header")
|
|
618
|
+
yield Static("", id="elapsed-time")
|
|
619
|
+
yield Rule()
|
|
620
|
+
|
|
621
|
+
# Build phases
|
|
622
|
+
yield Static("[bold]BUILD PROGRESS[/bold]", classes="section-header")
|
|
623
|
+
with Vertical(id="build-phases-list"):
|
|
624
|
+
for phase_name, phase_desc in self.build_phases:
|
|
625
|
+
with Horizontal(classes="phase-item"):
|
|
626
|
+
yield Static("○", classes="phase-icon pending", id=f"build-icon-{phase_name}")
|
|
627
|
+
yield Static(f"{phase_name}: {phase_desc}", classes="phase-name")
|
|
628
|
+
|
|
629
|
+
yield Rule()
|
|
630
|
+
|
|
631
|
+
# Service deployment phases
|
|
632
|
+
yield Static("[bold]SERVICE DEPLOYMENT[/bold]", classes="section-header")
|
|
633
|
+
with Vertical(id="service-phases-list"):
|
|
634
|
+
for phase_name, phase_desc in self.service_phases:
|
|
635
|
+
with Horizontal(classes="phase-item"):
|
|
636
|
+
yield Static("○", classes="phase-icon pending", id=f"svc-icon-{phase_name}")
|
|
637
|
+
yield Static(f"{phase_name}: {phase_desc}", classes="phase-name")
|
|
638
|
+
|
|
639
|
+
yield Rule()
|
|
640
|
+
yield Static("", id="app-url")
|
|
641
|
+
yield Static("", id="build-status")
|
|
642
|
+
yield ProgressBar(id="progress-bar", show_eta=False)
|
|
643
|
+
yield Footer()
|
|
644
|
+
|
|
645
|
+
def on_mount(self):
|
|
646
|
+
"""Start the progress animation."""
|
|
647
|
+
self.start_time = asyncio.get_event_loop().time()
|
|
648
|
+
self.last_poll_time = 0
|
|
649
|
+
self.set_interval(0.5, self.update_ui)
|
|
650
|
+
self.set_interval(1.0, self.update_timer)
|
|
651
|
+
self.set_interval(3.0, self.poll_status) # Poll API every 3 seconds
|
|
652
|
+
|
|
653
|
+
def update_timer(self):
|
|
654
|
+
"""Update elapsed time display."""
|
|
655
|
+
elapsed = int(asyncio.get_event_loop().time() - self.start_time)
|
|
656
|
+
minutes, seconds = divmod(elapsed, 60)
|
|
657
|
+
self.query_one("#elapsed-time", Static).update(f"Elapsed: {minutes}m {seconds}s")
|
|
658
|
+
|
|
659
|
+
def poll_status(self):
|
|
660
|
+
"""Poll the API for build/service status."""
|
|
661
|
+
if self.build_status in ("SUCCESS", "FAILED"):
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
if not self.sdk_client:
|
|
665
|
+
# Demo mode - simulate progress
|
|
666
|
+
self._simulate_progress()
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
image_name = f"{self.app_name}-image"
|
|
671
|
+
images = self.sdk_client.apps.get_images(namespace=self.namespace)
|
|
672
|
+
|
|
673
|
+
for img in images:
|
|
674
|
+
if image_name in img.get('metadata', {}).get('name', ''):
|
|
675
|
+
status = img.get('status', {})
|
|
676
|
+
conditions = status.get('conditions', [])
|
|
677
|
+
|
|
678
|
+
for cond in conditions:
|
|
679
|
+
if cond.get('type') == 'Ready':
|
|
680
|
+
if cond.get('status') == 'True':
|
|
681
|
+
if self.build_status == "BUILDING":
|
|
682
|
+
self.build_status = "DEPLOYING"
|
|
683
|
+
self.current_phase = len(self.build_phases)
|
|
684
|
+
elif self.build_status == "DEPLOYING":
|
|
685
|
+
self.build_status = "SUCCESS"
|
|
686
|
+
self.service_phase = len(self.service_phases)
|
|
687
|
+
self.app_url = f"https://{self.app_name}.{self.namespace}.apps.clue2.app"
|
|
688
|
+
else:
|
|
689
|
+
reason = cond.get('reason', '')
|
|
690
|
+
if 'Failed' in reason or 'Error' in reason:
|
|
691
|
+
self.build_status = "FAILED"
|
|
692
|
+
self.error_message = cond.get('message', 'Deployment failed')
|
|
693
|
+
break
|
|
694
|
+
break
|
|
695
|
+
except Exception as e:
|
|
696
|
+
self.error_message = str(e)
|
|
697
|
+
|
|
698
|
+
def _simulate_progress(self):
|
|
699
|
+
"""Simulate progress for demo mode."""
|
|
700
|
+
elapsed = asyncio.get_event_loop().time() - self.start_time
|
|
701
|
+
|
|
702
|
+
if self.build_status == "BUILDING":
|
|
703
|
+
self.current_phase = min(int(elapsed / 3), len(self.build_phases))
|
|
704
|
+
if self.current_phase >= len(self.build_phases):
|
|
705
|
+
self.build_status = "DEPLOYING"
|
|
706
|
+
elif self.build_status == "DEPLOYING":
|
|
707
|
+
build_time = len(self.build_phases) * 3
|
|
708
|
+
deploy_elapsed = elapsed - build_time
|
|
709
|
+
self.service_phase = min(int(deploy_elapsed / 2), len(self.service_phases))
|
|
710
|
+
if self.service_phase >= len(self.service_phases):
|
|
711
|
+
self.build_status = "SUCCESS"
|
|
712
|
+
self.app_url = f"https://{self.app_name}.{self.namespace}.apps.clue2.app"
|
|
713
|
+
|
|
714
|
+
def update_ui(self):
|
|
715
|
+
"""Update the UI based on current status."""
|
|
716
|
+
# Update build phase icons
|
|
717
|
+
for i, (phase_name, _) in enumerate(self.build_phases):
|
|
718
|
+
try:
|
|
719
|
+
icon = self.query_one(f"#build-icon-{phase_name}", Static)
|
|
720
|
+
if self.build_status in ("DEPLOYING", "SUCCESS"):
|
|
721
|
+
icon.update("[green]✓[/green]")
|
|
722
|
+
elif i < self.current_phase:
|
|
723
|
+
icon.update("[green]✓[/green]")
|
|
724
|
+
elif i == self.current_phase and self.build_status == "BUILDING":
|
|
725
|
+
icon.update("[blue]◉[/blue]")
|
|
726
|
+
else:
|
|
727
|
+
icon.update("○")
|
|
728
|
+
except NoMatches:
|
|
729
|
+
pass
|
|
730
|
+
|
|
731
|
+
# Update service phase icons
|
|
732
|
+
for i, (phase_name, _) in enumerate(self.service_phases):
|
|
733
|
+
try:
|
|
734
|
+
icon = self.query_one(f"#svc-icon-{phase_name}", Static)
|
|
735
|
+
if self.build_status == "SUCCESS":
|
|
736
|
+
icon.update("[green]✓[/green]")
|
|
737
|
+
elif self.build_status == "DEPLOYING":
|
|
738
|
+
if i < self.service_phase:
|
|
739
|
+
icon.update("[green]✓[/green]")
|
|
740
|
+
elif i == self.service_phase:
|
|
741
|
+
icon.update("[blue]◉[/blue]")
|
|
742
|
+
else:
|
|
743
|
+
icon.update("○")
|
|
744
|
+
else:
|
|
745
|
+
icon.update("○")
|
|
746
|
+
except NoMatches:
|
|
747
|
+
pass
|
|
748
|
+
|
|
749
|
+
# Update progress bar
|
|
750
|
+
total_phases = len(self.build_phases) + len(self.service_phases)
|
|
751
|
+
if self.build_status == "BUILDING":
|
|
752
|
+
progress = self.current_phase / total_phases
|
|
753
|
+
elif self.build_status == "DEPLOYING":
|
|
754
|
+
progress = (len(self.build_phases) + self.service_phase) / total_phases
|
|
755
|
+
else:
|
|
756
|
+
progress = 1.0 if self.build_status == "SUCCESS" else 0.5
|
|
757
|
+
|
|
758
|
+
try:
|
|
759
|
+
self.query_one("#progress-bar", ProgressBar).update(progress=progress * 100)
|
|
760
|
+
except NoMatches:
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
# Update status message
|
|
764
|
+
try:
|
|
765
|
+
status_widget = self.query_one("#build-status", Static)
|
|
766
|
+
if self.build_status == "BUILDING":
|
|
767
|
+
status_widget.update("[blue]● Building application...[/blue]")
|
|
768
|
+
elif self.build_status == "DEPLOYING":
|
|
769
|
+
status_widget.update("[blue]● Deploying service...[/blue]")
|
|
770
|
+
elif self.build_status == "SUCCESS":
|
|
771
|
+
status_widget.update("[green]✓ Deployment complete![/green]")
|
|
772
|
+
elif self.build_status == "FAILED":
|
|
773
|
+
status_widget.update(f"[red]✗ Failed: {self.error_message}[/red]")
|
|
774
|
+
except NoMatches:
|
|
775
|
+
pass
|
|
776
|
+
|
|
777
|
+
# Update URL display
|
|
778
|
+
if self.app_url:
|
|
779
|
+
try:
|
|
780
|
+
url_widget = self.query_one("#app-url", Static)
|
|
781
|
+
url_widget.update(f"[bold]Application URL:[/bold] [cyan]{self.app_url}[/cyan]")
|
|
782
|
+
except NoMatches:
|
|
783
|
+
pass
|
|
784
|
+
|
|
785
|
+
# Update header
|
|
786
|
+
try:
|
|
787
|
+
header = self.query_one("#main-header", Static)
|
|
788
|
+
if self.build_status == "SUCCESS":
|
|
789
|
+
header.update(f"✓ Deployed: {self.app_name}")
|
|
790
|
+
elif self.build_status == "FAILED":
|
|
791
|
+
header.update(f"✗ Failed: {self.app_name}")
|
|
792
|
+
except NoMatches:
|
|
793
|
+
pass
|
|
794
|
+
|
|
795
|
+
def action_cancel(self):
|
|
796
|
+
"""Exit and run in background."""
|
|
797
|
+
self.app.pop_screen()
|
|
798
|
+
|
|
799
|
+
def action_exit_screen(self):
|
|
800
|
+
"""Exit when deployment is complete."""
|
|
801
|
+
if self.build_status in ("SUCCESS", "FAILED"):
|
|
802
|
+
self.app.pop_screen()
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ============================================================================
|
|
806
|
+
# Main Wizard App
|
|
807
|
+
# ============================================================================
|
|
808
|
+
|
|
809
|
+
class DeployWizardApp(App):
|
|
810
|
+
"""Main Textual application for the deploy wizard."""
|
|
811
|
+
|
|
812
|
+
CSS = WIZARD_CSS
|
|
813
|
+
TITLE = "🚀 Deploy Application Wizard"
|
|
814
|
+
|
|
815
|
+
BINDINGS = [
|
|
816
|
+
Binding("escape", "quit", "Cancel"),
|
|
817
|
+
Binding("ctrl+c", "quit", "Cancel"),
|
|
818
|
+
Binding("ctrl+n", "next_step", "Next", show=True),
|
|
819
|
+
Binding("ctrl+b", "prev_step", "Back", show=True),
|
|
820
|
+
Binding("ctrl+enter", "next_step", "Next/Deploy"),
|
|
821
|
+
Binding("f2", "prev_step", "Previous"),
|
|
822
|
+
Binding("f3", "next_step", "Next"),
|
|
823
|
+
]
|
|
824
|
+
|
|
825
|
+
current_step = reactive(0)
|
|
826
|
+
wizard_data: Dict[str, Any] = {}
|
|
827
|
+
|
|
828
|
+
def __init__(self, projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None, **kwargs):
|
|
829
|
+
super().__init__(**kwargs)
|
|
830
|
+
self.projects = projects or []
|
|
831
|
+
self.deploy_func = deploy_func
|
|
832
|
+
self.sdk_client = sdk_client
|
|
833
|
+
self.wizard_data = {}
|
|
834
|
+
self.steps = []
|
|
835
|
+
|
|
836
|
+
def compose(self) -> ComposeResult:
|
|
837
|
+
yield Header(show_clock=True)
|
|
838
|
+
yield Container(
|
|
839
|
+
StepIndicator(id="step-indicator"),
|
|
840
|
+
id="step-indicator-container",
|
|
841
|
+
)
|
|
842
|
+
yield ScrollableContainer(id="content-area")
|
|
843
|
+
with Horizontal(id="footer-actions"):
|
|
844
|
+
yield Button("Cancel", id="btn-cancel", variant="default")
|
|
845
|
+
yield Button("← Previous", id="btn-prev", variant="default", disabled=True)
|
|
846
|
+
yield Button("Next →", id="btn-next", variant="primary")
|
|
847
|
+
yield Footer()
|
|
848
|
+
|
|
849
|
+
def on_mount(self):
|
|
850
|
+
"""Initialize the wizard."""
|
|
851
|
+
self.show_step(0)
|
|
852
|
+
|
|
853
|
+
def show_step(self, step: int):
|
|
854
|
+
"""Display a specific step."""
|
|
855
|
+
self.current_step = step
|
|
856
|
+
|
|
857
|
+
# Update step indicator
|
|
858
|
+
indicator = self.query_one("#step-indicator", StepIndicator)
|
|
859
|
+
indicator.set_step(step)
|
|
860
|
+
|
|
861
|
+
# Update content
|
|
862
|
+
content = self.query_one("#content-area", ScrollableContainer)
|
|
863
|
+
content.remove_children()
|
|
864
|
+
|
|
865
|
+
if step == 0:
|
|
866
|
+
content.mount(BasicsStep(projects=self.projects, classes="wizard-step fade-in"))
|
|
867
|
+
elif step == 1:
|
|
868
|
+
content.mount(SourceStep(classes="wizard-step fade-in"))
|
|
869
|
+
elif step == 2:
|
|
870
|
+
content.mount(RegistryStep(classes="wizard-step fade-in"))
|
|
871
|
+
elif step == 3:
|
|
872
|
+
content.mount(SettingsStep(classes="wizard-step fade-in"))
|
|
873
|
+
elif step == 4:
|
|
874
|
+
content.mount(ReviewStep(self.wizard_data, classes="wizard-step fade-in"))
|
|
875
|
+
|
|
876
|
+
# Update buttons
|
|
877
|
+
self.query_one("#btn-prev", Button).disabled = (step == 0)
|
|
878
|
+
next_btn = self.query_one("#btn-next", Button)
|
|
879
|
+
if step == 4:
|
|
880
|
+
next_btn.label = "🚀 Deploy"
|
|
881
|
+
next_btn.variant = "success"
|
|
882
|
+
else:
|
|
883
|
+
next_btn.label = "Next →"
|
|
884
|
+
next_btn.variant = "primary"
|
|
885
|
+
|
|
886
|
+
def collect_step_data(self) -> Dict[str, Any]:
|
|
887
|
+
"""Collect data from the current step."""
|
|
888
|
+
content = self.query_one("#content-area", ScrollableContainer)
|
|
889
|
+
children = list(content.children)
|
|
890
|
+
if children and hasattr(children[0], 'get_data'):
|
|
891
|
+
return children[0].get_data()
|
|
892
|
+
return {}
|
|
893
|
+
|
|
894
|
+
@on(Button.Pressed, "#btn-cancel")
|
|
895
|
+
def on_cancel(self):
|
|
896
|
+
"""Handle cancel button."""
|
|
897
|
+
self.exit(None)
|
|
898
|
+
|
|
899
|
+
@on(Button.Pressed, "#btn-prev")
|
|
900
|
+
def on_previous(self):
|
|
901
|
+
"""Handle previous button."""
|
|
902
|
+
self.action_prev_step()
|
|
903
|
+
|
|
904
|
+
def action_prev_step(self):
|
|
905
|
+
"""Go to previous step (keyboard shortcut: Ctrl+P)."""
|
|
906
|
+
if self.current_step > 0:
|
|
907
|
+
self.show_step(self.current_step - 1)
|
|
908
|
+
|
|
909
|
+
@on(Button.Pressed, "#btn-next")
|
|
910
|
+
def on_next(self):
|
|
911
|
+
"""Handle next button."""
|
|
912
|
+
self.action_next_step()
|
|
913
|
+
|
|
914
|
+
def action_next_step(self):
|
|
915
|
+
"""Go to next step or deploy (keyboard shortcut: Ctrl+N or Ctrl+Enter)."""
|
|
916
|
+
# Collect current step data
|
|
917
|
+
step_data = self.collect_step_data()
|
|
918
|
+
self.wizard_data.update(step_data)
|
|
919
|
+
|
|
920
|
+
# Mark step as completed
|
|
921
|
+
indicator = self.query_one("#step-indicator", StepIndicator)
|
|
922
|
+
indicator.complete_step(self.current_step)
|
|
923
|
+
|
|
924
|
+
if self.current_step < 4:
|
|
925
|
+
# Go to next step
|
|
926
|
+
self.show_step(self.current_step + 1)
|
|
927
|
+
else:
|
|
928
|
+
# Deploy
|
|
929
|
+
self.wizard_data['confirmed'] = True
|
|
930
|
+
if self.deploy_func:
|
|
931
|
+
self.run_deployment()
|
|
932
|
+
else:
|
|
933
|
+
self.exit(self.wizard_data)
|
|
934
|
+
|
|
935
|
+
@work
|
|
936
|
+
async def run_deployment(self):
|
|
937
|
+
"""Run the deployment and show progress."""
|
|
938
|
+
# Call deploy function first to initiate deployment
|
|
939
|
+
if self.deploy_func:
|
|
940
|
+
try:
|
|
941
|
+
self.deploy_func(self.wizard_data)
|
|
942
|
+
except Exception as e:
|
|
943
|
+
self.notify(f"Deployment error: {e}", severity="error")
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
# Get namespace from wizard data
|
|
947
|
+
namespace = self.wizard_data.get('namespace') or \
|
|
948
|
+
self.wizard_data.get('project', {}).get('ns', 'default')
|
|
949
|
+
|
|
950
|
+
# Push build progress screen with SDK client for live updates
|
|
951
|
+
await self.push_screen(BuildProgressScreen(
|
|
952
|
+
app_name=self.wizard_data.get('app_name', 'app'),
|
|
953
|
+
namespace=namespace,
|
|
954
|
+
sdk_client=self.sdk_client,
|
|
955
|
+
))
|
|
956
|
+
|
|
957
|
+
# Wait for screen to complete (will be popped when done)
|
|
958
|
+
# The screen handles its own exit via action_exit_screen
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ============================================================================
|
|
962
|
+
# Public API
|
|
963
|
+
# ============================================================================
|
|
964
|
+
|
|
965
|
+
def run_textual_wizard(projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None) -> Optional[Dict[str, Any]]:
|
|
966
|
+
"""
|
|
967
|
+
Run the Textual-based deploy wizard.
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
projects: List of available projects
|
|
971
|
+
deploy_func: Function to call for actual deployment
|
|
972
|
+
sdk_client: SDK client for API calls and live progress monitoring
|
|
973
|
+
|
|
974
|
+
Returns:
|
|
975
|
+
Wizard data if completed, None if cancelled
|
|
976
|
+
"""
|
|
977
|
+
app = DeployWizardApp(projects=projects, deploy_func=deploy_func, sdk_client=sdk_client)
|
|
978
|
+
return app.run()
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
if __name__ == "__main__":
|
|
982
|
+
# Demo mode
|
|
983
|
+
result = run_textual_wizard()
|
|
984
|
+
if result:
|
|
985
|
+
print(f"Wizard completed with data: {result}")
|
|
986
|
+
else:
|
|
987
|
+
print("Wizard cancelled")
|