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,839 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ncurses-based Deploy Wizard for Clue2App CLI.
|
|
4
|
+
|
|
5
|
+
A simple, traditional terminal UI using Python's curses library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import curses
|
|
9
|
+
import time
|
|
10
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WizardField:
|
|
16
|
+
"""A form field in the wizard."""
|
|
17
|
+
name: str
|
|
18
|
+
label: str
|
|
19
|
+
field_type: str = "text" # text, password, select, checkbox
|
|
20
|
+
options: List[str] = field(default_factory=list)
|
|
21
|
+
default: str = ""
|
|
22
|
+
required: bool = False
|
|
23
|
+
value: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class WizardStep:
|
|
28
|
+
"""A step in the wizard."""
|
|
29
|
+
name: str
|
|
30
|
+
title: str
|
|
31
|
+
description: str
|
|
32
|
+
fields: List[WizardField] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CursesWizard:
|
|
36
|
+
"""Ncurses-based deploy wizard with animations."""
|
|
37
|
+
|
|
38
|
+
# Spinner animation frames
|
|
39
|
+
SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
40
|
+
|
|
41
|
+
# Colors
|
|
42
|
+
COLOR_TITLE = 1
|
|
43
|
+
COLOR_ACTIVE = 2
|
|
44
|
+
COLOR_COMPLETED = 3
|
|
45
|
+
COLOR_PENDING = 4
|
|
46
|
+
COLOR_INPUT = 5
|
|
47
|
+
COLOR_ERROR = 6
|
|
48
|
+
COLOR_SUCCESS = 7
|
|
49
|
+
|
|
50
|
+
# Build phases for progress display
|
|
51
|
+
BUILD_PHASES = [
|
|
52
|
+
("FETCHING", "Fetching source code"),
|
|
53
|
+
("DETECTING", "Detecting buildpack"),
|
|
54
|
+
("ANALYZING", "Analyzing dependencies"),
|
|
55
|
+
("BUILDING", "Building application"),
|
|
56
|
+
("EXPORTING", "Exporting image"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Service deployment states
|
|
60
|
+
SERVICE_STATES = [
|
|
61
|
+
("PENDING", "Service pending"),
|
|
62
|
+
("CREATING", "Creating pods"),
|
|
63
|
+
("CONFIGURING", "Configuring networking"),
|
|
64
|
+
("SCALING", "Scaling replicas"),
|
|
65
|
+
("READY", "Service ready"),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
STEPS = [
|
|
69
|
+
WizardStep(
|
|
70
|
+
name="basics",
|
|
71
|
+
title="Application Basics",
|
|
72
|
+
description="Enter your application name",
|
|
73
|
+
fields=[
|
|
74
|
+
WizardField("app_name", "Application Name", required=True),
|
|
75
|
+
]
|
|
76
|
+
),
|
|
77
|
+
WizardStep(
|
|
78
|
+
name="source",
|
|
79
|
+
title="Source Code",
|
|
80
|
+
description="Configure your Git repository",
|
|
81
|
+
fields=[
|
|
82
|
+
WizardField("git_url", "Git Repository URL", required=True),
|
|
83
|
+
WizardField("branch", "Branch", default="main"),
|
|
84
|
+
WizardField("is_private", "Private Repository?", field_type="checkbox"),
|
|
85
|
+
WizardField("git_token", "Git Token (for private repos)", field_type="password"),
|
|
86
|
+
]
|
|
87
|
+
),
|
|
88
|
+
WizardStep(
|
|
89
|
+
name="registry",
|
|
90
|
+
title="Container Registry",
|
|
91
|
+
description="Choose where to store container images",
|
|
92
|
+
fields=[
|
|
93
|
+
WizardField("registry_type", "Registry Type", field_type="select",
|
|
94
|
+
options=["Platform Registry", "Private Registry"],
|
|
95
|
+
default="Platform Registry"),
|
|
96
|
+
WizardField("registry_url", "Registry URL (for private)"),
|
|
97
|
+
WizardField("registry_user", "Registry Username"),
|
|
98
|
+
WizardField("registry_pass", "Registry Password", field_type="password"),
|
|
99
|
+
]
|
|
100
|
+
),
|
|
101
|
+
WizardStep(
|
|
102
|
+
name="settings",
|
|
103
|
+
title="Application Settings",
|
|
104
|
+
description="Configure deployment settings",
|
|
105
|
+
fields=[
|
|
106
|
+
WizardField("port", "Container Port", default="8080"),
|
|
107
|
+
WizardField("size", "Application Size", field_type="select",
|
|
108
|
+
options=["TINY", "SMALL", "MEDIUM", "LARGE"],
|
|
109
|
+
default="SMALL"),
|
|
110
|
+
WizardField("auto_deploy", "Auto Deploy?", field_type="checkbox", default="yes"),
|
|
111
|
+
WizardField("scale_to_zero", "Scale to Zero?", field_type="checkbox", default="no"),
|
|
112
|
+
]
|
|
113
|
+
),
|
|
114
|
+
WizardStep(
|
|
115
|
+
name="review",
|
|
116
|
+
title="Review & Deploy",
|
|
117
|
+
description="Review your configuration",
|
|
118
|
+
fields=[]
|
|
119
|
+
),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
def __init__(self, projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None):
|
|
123
|
+
self.projects = projects or []
|
|
124
|
+
self.deploy_func = deploy_func
|
|
125
|
+
self.sdk_client = sdk_client
|
|
126
|
+
self.current_step = 0
|
|
127
|
+
self.current_field = 0
|
|
128
|
+
self.data: Dict[str, Any] = {}
|
|
129
|
+
self.stdscr = None
|
|
130
|
+
self.spinner_frame = 0
|
|
131
|
+
self.has_colors = False
|
|
132
|
+
# Deployment state
|
|
133
|
+
self.deploy_status = "IDLE" # IDLE, BUILDING, DEPLOYING, SUCCESS, FAILED
|
|
134
|
+
self.build_phase = 0
|
|
135
|
+
self.service_state = 0
|
|
136
|
+
self.deploy_start_time = 0
|
|
137
|
+
self.app_url: Optional[str] = None
|
|
138
|
+
self.error_message: Optional[str] = None
|
|
139
|
+
# Dynamically add project selection if multiple projects
|
|
140
|
+
if len(self.projects) > 1:
|
|
141
|
+
project_options = [f"{p.get('name')} ({p.get('ns', 'default')})" for p in self.projects]
|
|
142
|
+
# Insert project field after app_name in basics step
|
|
143
|
+
basics_step = self.STEPS[0]
|
|
144
|
+
basics_step.fields.append(
|
|
145
|
+
WizardField("project", "Target Project", field_type="select",
|
|
146
|
+
options=project_options, default=project_options[0] if project_options else None)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def init_colors(self):
|
|
150
|
+
"""Initialize color pairs if terminal supports colors."""
|
|
151
|
+
if curses.has_colors():
|
|
152
|
+
self.has_colors = True
|
|
153
|
+
curses.start_color()
|
|
154
|
+
curses.use_default_colors()
|
|
155
|
+
curses.init_pair(self.COLOR_TITLE, curses.COLOR_CYAN, -1)
|
|
156
|
+
curses.init_pair(self.COLOR_ACTIVE, curses.COLOR_BLUE, -1)
|
|
157
|
+
curses.init_pair(self.COLOR_COMPLETED, curses.COLOR_GREEN, -1)
|
|
158
|
+
curses.init_pair(self.COLOR_PENDING, curses.COLOR_WHITE, -1)
|
|
159
|
+
curses.init_pair(self.COLOR_INPUT, curses.COLOR_YELLOW, -1)
|
|
160
|
+
curses.init_pair(self.COLOR_ERROR, curses.COLOR_RED, -1)
|
|
161
|
+
curses.init_pair(self.COLOR_SUCCESS, curses.COLOR_GREEN, -1)
|
|
162
|
+
|
|
163
|
+
def draw_header(self):
|
|
164
|
+
"""Draw the header with step indicator and colors."""
|
|
165
|
+
h, w = self.stdscr.getmaxyx()
|
|
166
|
+
|
|
167
|
+
# Animated spinner
|
|
168
|
+
spinner = self.SPINNER[self.spinner_frame % len(self.SPINNER)]
|
|
169
|
+
self.spinner_frame += 1
|
|
170
|
+
|
|
171
|
+
# Title with color
|
|
172
|
+
title = f" {spinner} Deploy Application Wizard {spinner} "
|
|
173
|
+
if self.has_colors:
|
|
174
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_TITLE) | curses.A_BOLD)
|
|
175
|
+
else:
|
|
176
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
177
|
+
self.stdscr.addstr(0, (w - len(title)) // 2, title)
|
|
178
|
+
if self.has_colors:
|
|
179
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_TITLE) | curses.A_BOLD)
|
|
180
|
+
else:
|
|
181
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
182
|
+
|
|
183
|
+
# Step indicator with colors
|
|
184
|
+
x = 2
|
|
185
|
+
for i, step in enumerate(self.STEPS):
|
|
186
|
+
if i < self.current_step:
|
|
187
|
+
marker = "✓"
|
|
188
|
+
if self.has_colors:
|
|
189
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
190
|
+
elif i == self.current_step:
|
|
191
|
+
marker = "●"
|
|
192
|
+
if self.has_colors:
|
|
193
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_ACTIVE) | curses.A_BOLD)
|
|
194
|
+
else:
|
|
195
|
+
marker = "○"
|
|
196
|
+
if self.has_colors:
|
|
197
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_PENDING))
|
|
198
|
+
|
|
199
|
+
step_text = f" {marker} {step.name.capitalize()} "
|
|
200
|
+
if x + len(step_text) < w:
|
|
201
|
+
self.stdscr.addstr(2, x, step_text)
|
|
202
|
+
x += len(step_text)
|
|
203
|
+
|
|
204
|
+
# Reset color
|
|
205
|
+
if self.has_colors:
|
|
206
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_COMPLETED))
|
|
207
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_ACTIVE) | curses.A_BOLD)
|
|
208
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_PENDING))
|
|
209
|
+
|
|
210
|
+
# Connector
|
|
211
|
+
if i < len(self.STEPS) - 1 and x + 3 < w:
|
|
212
|
+
connector = "──"
|
|
213
|
+
if i < self.current_step and self.has_colors:
|
|
214
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
215
|
+
self.stdscr.addstr(2, x, connector)
|
|
216
|
+
if i < self.current_step and self.has_colors:
|
|
217
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_COMPLETED))
|
|
218
|
+
x += len(connector)
|
|
219
|
+
|
|
220
|
+
# Horizontal line
|
|
221
|
+
self.stdscr.addstr(3, 0, "─" * (w - 1))
|
|
222
|
+
|
|
223
|
+
def draw_step(self):
|
|
224
|
+
"""Draw the current step content."""
|
|
225
|
+
h, w = self.stdscr.getmaxyx()
|
|
226
|
+
step = self.STEPS[self.current_step]
|
|
227
|
+
|
|
228
|
+
# Step title
|
|
229
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
230
|
+
self.stdscr.addstr(5, 2, f"Step {self.current_step + 1}: {step.title}")
|
|
231
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
232
|
+
|
|
233
|
+
# Description
|
|
234
|
+
self.stdscr.addstr(6, 2, step.description)
|
|
235
|
+
|
|
236
|
+
# Fields
|
|
237
|
+
if step.name == "review":
|
|
238
|
+
self.draw_review()
|
|
239
|
+
else:
|
|
240
|
+
self.draw_fields(step)
|
|
241
|
+
|
|
242
|
+
def draw_fields(self, step: WizardStep):
|
|
243
|
+
"""Draw form fields for a step."""
|
|
244
|
+
h, w = self.stdscr.getmaxyx()
|
|
245
|
+
y = 8
|
|
246
|
+
|
|
247
|
+
for i, field in enumerate(step.fields):
|
|
248
|
+
# Highlight current field
|
|
249
|
+
if i == self.current_field:
|
|
250
|
+
self.stdscr.attron(curses.A_REVERSE)
|
|
251
|
+
|
|
252
|
+
label = f" {field.label}: "
|
|
253
|
+
self.stdscr.addstr(y, 2, label)
|
|
254
|
+
|
|
255
|
+
if i == self.current_field:
|
|
256
|
+
self.stdscr.attroff(curses.A_REVERSE)
|
|
257
|
+
|
|
258
|
+
# Field value
|
|
259
|
+
if field.field_type == "checkbox":
|
|
260
|
+
value = "[X]" if field.value in ["yes", "true", True] else "[ ]"
|
|
261
|
+
elif field.field_type == "select":
|
|
262
|
+
value = f"< {field.value or field.default} >"
|
|
263
|
+
else:
|
|
264
|
+
value = field.value or field.default or "_" * 20
|
|
265
|
+
|
|
266
|
+
self.stdscr.addstr(y, 2 + len(label), value[:w - len(label) - 5])
|
|
267
|
+
y += 2
|
|
268
|
+
|
|
269
|
+
def draw_review(self):
|
|
270
|
+
"""Draw the review screen."""
|
|
271
|
+
h, w = self.stdscr.getmaxyx()
|
|
272
|
+
y = 8
|
|
273
|
+
|
|
274
|
+
# Build source code section
|
|
275
|
+
source_items = [
|
|
276
|
+
("Git Repository", self.data.get("git_url", "N/A")),
|
|
277
|
+
("Branch", self.data.get("branch", "main")),
|
|
278
|
+
("Private", "Yes" if self.data.get("is_private") else "No"),
|
|
279
|
+
]
|
|
280
|
+
if self.data.get("is_private") and self.data.get("git_token"):
|
|
281
|
+
source_items.append(("Git Token", "********"))
|
|
282
|
+
|
|
283
|
+
# Build registry section
|
|
284
|
+
registry_items = [
|
|
285
|
+
("Type", self.data.get("registry_type", "Platform Registry")),
|
|
286
|
+
]
|
|
287
|
+
if self.data.get("registry_type") == "Private Registry":
|
|
288
|
+
if self.data.get("registry_url"):
|
|
289
|
+
registry_items.append(("Registry URL", self.data.get("registry_url")))
|
|
290
|
+
if self.data.get("registry_user"):
|
|
291
|
+
registry_items.append(("Username", self.data.get("registry_user")))
|
|
292
|
+
if self.data.get("registry_pass"):
|
|
293
|
+
registry_items.append(("Password", "********"))
|
|
294
|
+
|
|
295
|
+
# Build basic info section
|
|
296
|
+
basic_items = [("Application Name", self.data.get("app_name", "N/A"))]
|
|
297
|
+
if self.data.get("project"):
|
|
298
|
+
basic_items.append(("Project", self.data.get("project")))
|
|
299
|
+
|
|
300
|
+
sections = [
|
|
301
|
+
("BASIC INFO", basic_items),
|
|
302
|
+
("SOURCE CODE", source_items),
|
|
303
|
+
("REGISTRY", registry_items),
|
|
304
|
+
("SETTINGS", [
|
|
305
|
+
("Port", self.data.get("port", "8080")),
|
|
306
|
+
("Size", self.data.get("size", "SMALL")),
|
|
307
|
+
("Auto Deploy", "Yes" if self.data.get("auto_deploy") else "No"),
|
|
308
|
+
("Scale to Zero", "Yes" if self.data.get("scale_to_zero") else "No"),
|
|
309
|
+
]),
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
for section_title, items in sections:
|
|
313
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
314
|
+
self.stdscr.addstr(y, 2, section_title)
|
|
315
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
316
|
+
y += 1
|
|
317
|
+
|
|
318
|
+
for label, value in items:
|
|
319
|
+
value_str = str(value)
|
|
320
|
+
# For long values like URLs, show on next line if needed
|
|
321
|
+
if len(f"{label}: {value_str}") > w - 6 and len(value_str) > 30:
|
|
322
|
+
self.stdscr.addstr(y, 4, f"{label}:")
|
|
323
|
+
y += 1
|
|
324
|
+
# Truncate long URLs but show more of them
|
|
325
|
+
self.stdscr.addstr(y, 6, value_str[:w-8])
|
|
326
|
+
else:
|
|
327
|
+
self.stdscr.addstr(y, 4, f"{label}: {value_str}"[:w-6])
|
|
328
|
+
y += 1
|
|
329
|
+
y += 1
|
|
330
|
+
|
|
331
|
+
def draw_footer(self):
|
|
332
|
+
"""Draw the footer with controls."""
|
|
333
|
+
h, w = self.stdscr.getmaxyx()
|
|
334
|
+
|
|
335
|
+
# Horizontal line
|
|
336
|
+
self.stdscr.addstr(h - 3, 0, "─" * (w - 1))
|
|
337
|
+
|
|
338
|
+
# Controls
|
|
339
|
+
if self.current_step == len(self.STEPS) - 1:
|
|
340
|
+
controls = "[Enter] Deploy [←] Back [q] Cancel"
|
|
341
|
+
else:
|
|
342
|
+
controls = "[Enter] Next [←] Back [Tab] Next Field [q] Cancel"
|
|
343
|
+
|
|
344
|
+
self.stdscr.addstr(h - 2, (w - len(controls)) // 2, controls)
|
|
345
|
+
|
|
346
|
+
def collect_step_data(self):
|
|
347
|
+
"""Collect data from current step."""
|
|
348
|
+
step = self.STEPS[self.current_step]
|
|
349
|
+
for field in step.fields:
|
|
350
|
+
if field.field_type == "checkbox":
|
|
351
|
+
self.data[field.name] = field.value in ["yes", "true", True]
|
|
352
|
+
else:
|
|
353
|
+
self.data[field.name] = field.value or field.default
|
|
354
|
+
|
|
355
|
+
def handle_input(self, key):
|
|
356
|
+
"""Handle keyboard input."""
|
|
357
|
+
step = self.STEPS[self.current_step]
|
|
358
|
+
|
|
359
|
+
if key == ord('q'):
|
|
360
|
+
return False # Quit
|
|
361
|
+
|
|
362
|
+
elif key == curses.KEY_LEFT or key == ord('b'):
|
|
363
|
+
# Go back
|
|
364
|
+
if self.current_step > 0:
|
|
365
|
+
self.collect_step_data()
|
|
366
|
+
self.current_step -= 1
|
|
367
|
+
self.current_field = 0
|
|
368
|
+
|
|
369
|
+
elif key == ord('\n') or key == curses.KEY_RIGHT:
|
|
370
|
+
# Next step or submit
|
|
371
|
+
self.collect_step_data()
|
|
372
|
+
if self.current_step < len(self.STEPS) - 1:
|
|
373
|
+
self.current_step += 1
|
|
374
|
+
self.current_field = 0
|
|
375
|
+
else:
|
|
376
|
+
# Deploy
|
|
377
|
+
self.data['confirmed'] = True
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
elif key == ord('\t') or key == curses.KEY_DOWN:
|
|
381
|
+
# Next field
|
|
382
|
+
if step.fields and self.current_field < len(step.fields) - 1:
|
|
383
|
+
self.current_field += 1
|
|
384
|
+
|
|
385
|
+
elif key == curses.KEY_UP:
|
|
386
|
+
# Previous field
|
|
387
|
+
if self.current_field > 0:
|
|
388
|
+
self.current_field -= 1
|
|
389
|
+
|
|
390
|
+
elif key == ord(' '):
|
|
391
|
+
# Toggle checkbox or cycle select
|
|
392
|
+
if step.fields:
|
|
393
|
+
field = step.fields[self.current_field]
|
|
394
|
+
if field.field_type == "checkbox":
|
|
395
|
+
field.value = "" if field.value else "yes"
|
|
396
|
+
elif field.field_type == "select":
|
|
397
|
+
idx = field.options.index(field.value or field.default) if (field.value or field.default) in field.options else 0
|
|
398
|
+
field.value = field.options[(idx + 1) % len(field.options)]
|
|
399
|
+
|
|
400
|
+
elif key == curses.KEY_BACKSPACE or key == 127:
|
|
401
|
+
# Delete character
|
|
402
|
+
if step.fields:
|
|
403
|
+
field = step.fields[self.current_field]
|
|
404
|
+
if field.field_type == "text":
|
|
405
|
+
field.value = field.value[:-1]
|
|
406
|
+
|
|
407
|
+
elif 32 <= key <= 126:
|
|
408
|
+
# Regular character input
|
|
409
|
+
if step.fields:
|
|
410
|
+
field = step.fields[self.current_field]
|
|
411
|
+
if field.field_type == "text":
|
|
412
|
+
field.value += chr(key)
|
|
413
|
+
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
def draw_deployment_header(self):
|
|
417
|
+
"""Draw header for deployment progress screen."""
|
|
418
|
+
h, w = self.stdscr.getmaxyx()
|
|
419
|
+
|
|
420
|
+
# Animated spinner
|
|
421
|
+
spinner = self.SPINNER[self.spinner_frame % len(self.SPINNER)]
|
|
422
|
+
self.spinner_frame += 1
|
|
423
|
+
|
|
424
|
+
# Title based on status
|
|
425
|
+
if self.deploy_status == "BUILDING":
|
|
426
|
+
title = f" {spinner} Building Application {spinner} "
|
|
427
|
+
color = self.COLOR_ACTIVE
|
|
428
|
+
elif self.deploy_status == "DEPLOYING":
|
|
429
|
+
title = f" {spinner} Deploying Service {spinner} "
|
|
430
|
+
color = self.COLOR_ACTIVE
|
|
431
|
+
elif self.deploy_status == "SUCCESS":
|
|
432
|
+
title = " ✓ Deployment Complete! "
|
|
433
|
+
color = self.COLOR_SUCCESS
|
|
434
|
+
elif self.deploy_status == "FAILED":
|
|
435
|
+
title = " ✗ Deployment Failed "
|
|
436
|
+
color = self.COLOR_ERROR
|
|
437
|
+
else:
|
|
438
|
+
title = " Deployment "
|
|
439
|
+
color = self.COLOR_TITLE
|
|
440
|
+
|
|
441
|
+
if self.has_colors:
|
|
442
|
+
self.stdscr.attron(curses.color_pair(color) | curses.A_BOLD)
|
|
443
|
+
else:
|
|
444
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
445
|
+
self.stdscr.addstr(0, (w - len(title)) // 2, title)
|
|
446
|
+
if self.has_colors:
|
|
447
|
+
self.stdscr.attroff(curses.color_pair(color) | curses.A_BOLD)
|
|
448
|
+
else:
|
|
449
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
450
|
+
|
|
451
|
+
# Horizontal line
|
|
452
|
+
self.stdscr.addstr(1, 0, "─" * (w - 1))
|
|
453
|
+
|
|
454
|
+
def draw_deployment_progress(self):
|
|
455
|
+
"""Draw the deployment progress display."""
|
|
456
|
+
h, w = self.stdscr.getmaxyx()
|
|
457
|
+
y = 3
|
|
458
|
+
|
|
459
|
+
# Elapsed time
|
|
460
|
+
elapsed = int(time.time() - self.deploy_start_time)
|
|
461
|
+
elapsed_str = f"{elapsed // 60}m {elapsed % 60}s"
|
|
462
|
+
|
|
463
|
+
app_name = self.data.get('app_name', 'app')
|
|
464
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
465
|
+
self.stdscr.addstr(y, 2, f"Application: {app_name}")
|
|
466
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
467
|
+
self.stdscr.addstr(y, w - len(elapsed_str) - 4, f"[{elapsed_str}]")
|
|
468
|
+
y += 2
|
|
469
|
+
|
|
470
|
+
# Build progress section
|
|
471
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
472
|
+
self.stdscr.addstr(y, 2, "BUILD PROGRESS")
|
|
473
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
474
|
+
y += 1
|
|
475
|
+
|
|
476
|
+
for i, (phase_name, phase_desc) in enumerate(self.BUILD_PHASES):
|
|
477
|
+
if self.deploy_status in ("DEPLOYING", "SUCCESS"):
|
|
478
|
+
# All build phases complete
|
|
479
|
+
marker = "✓"
|
|
480
|
+
if self.has_colors:
|
|
481
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
482
|
+
elif self.deploy_status == "FAILED" and i <= self.build_phase:
|
|
483
|
+
if i < self.build_phase:
|
|
484
|
+
marker = "✓"
|
|
485
|
+
if self.has_colors:
|
|
486
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
487
|
+
else:
|
|
488
|
+
marker = "✗"
|
|
489
|
+
if self.has_colors:
|
|
490
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_ERROR))
|
|
491
|
+
elif i < self.build_phase:
|
|
492
|
+
marker = "✓"
|
|
493
|
+
if self.has_colors:
|
|
494
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
495
|
+
elif i == self.build_phase and self.deploy_status == "BUILDING":
|
|
496
|
+
marker = self.SPINNER[self.spinner_frame % len(self.SPINNER)]
|
|
497
|
+
if self.has_colors:
|
|
498
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_ACTIVE) | curses.A_BOLD)
|
|
499
|
+
else:
|
|
500
|
+
marker = "○"
|
|
501
|
+
if self.has_colors:
|
|
502
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_PENDING))
|
|
503
|
+
|
|
504
|
+
self.stdscr.addstr(y, 4, f"{marker} {phase_name}: {phase_desc}"[:w-6])
|
|
505
|
+
|
|
506
|
+
# Reset colors
|
|
507
|
+
if self.has_colors:
|
|
508
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_COMPLETED))
|
|
509
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_ACTIVE) | curses.A_BOLD)
|
|
510
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_PENDING))
|
|
511
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_ERROR))
|
|
512
|
+
y += 1
|
|
513
|
+
|
|
514
|
+
y += 1
|
|
515
|
+
|
|
516
|
+
# Service deployment section
|
|
517
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
518
|
+
self.stdscr.addstr(y, 2, "SERVICE DEPLOYMENT")
|
|
519
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
520
|
+
y += 1
|
|
521
|
+
|
|
522
|
+
for i, (state_name, state_desc) in enumerate(self.SERVICE_STATES):
|
|
523
|
+
if self.deploy_status == "SUCCESS":
|
|
524
|
+
marker = "✓"
|
|
525
|
+
if self.has_colors:
|
|
526
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
527
|
+
elif self.deploy_status == "FAILED" and self.deploy_status != "BUILDING":
|
|
528
|
+
if i <= self.service_state:
|
|
529
|
+
if i < self.service_state:
|
|
530
|
+
marker = "✓"
|
|
531
|
+
if self.has_colors:
|
|
532
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
533
|
+
else:
|
|
534
|
+
marker = "✗"
|
|
535
|
+
if self.has_colors:
|
|
536
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_ERROR))
|
|
537
|
+
else:
|
|
538
|
+
marker = "○"
|
|
539
|
+
if self.has_colors:
|
|
540
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_PENDING))
|
|
541
|
+
elif self.deploy_status == "DEPLOYING":
|
|
542
|
+
if i < self.service_state:
|
|
543
|
+
marker = "✓"
|
|
544
|
+
if self.has_colors:
|
|
545
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_COMPLETED))
|
|
546
|
+
elif i == self.service_state:
|
|
547
|
+
marker = self.SPINNER[self.spinner_frame % len(self.SPINNER)]
|
|
548
|
+
if self.has_colors:
|
|
549
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_ACTIVE) | curses.A_BOLD)
|
|
550
|
+
else:
|
|
551
|
+
marker = "○"
|
|
552
|
+
if self.has_colors:
|
|
553
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_PENDING))
|
|
554
|
+
else:
|
|
555
|
+
# Still building
|
|
556
|
+
marker = "○"
|
|
557
|
+
if self.has_colors:
|
|
558
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_PENDING))
|
|
559
|
+
|
|
560
|
+
self.stdscr.addstr(y, 4, f"{marker} {state_name}: {state_desc}"[:w-6])
|
|
561
|
+
|
|
562
|
+
# Reset colors
|
|
563
|
+
if self.has_colors:
|
|
564
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_COMPLETED))
|
|
565
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_ACTIVE) | curses.A_BOLD)
|
|
566
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_PENDING))
|
|
567
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_ERROR))
|
|
568
|
+
y += 1
|
|
569
|
+
|
|
570
|
+
y += 1
|
|
571
|
+
|
|
572
|
+
# URL display
|
|
573
|
+
if self.app_url:
|
|
574
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
575
|
+
self.stdscr.addstr(y, 2, "APPLICATION URL")
|
|
576
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
577
|
+
y += 1
|
|
578
|
+
if self.has_colors:
|
|
579
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_TITLE))
|
|
580
|
+
self.stdscr.addstr(y, 4, self.app_url[:w-6])
|
|
581
|
+
if self.has_colors:
|
|
582
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_TITLE))
|
|
583
|
+
y += 2
|
|
584
|
+
|
|
585
|
+
# Error message
|
|
586
|
+
if self.error_message:
|
|
587
|
+
if self.has_colors:
|
|
588
|
+
self.stdscr.attron(curses.color_pair(self.COLOR_ERROR))
|
|
589
|
+
self.stdscr.addstr(y, 2, f"Error: {self.error_message}"[:w-4])
|
|
590
|
+
if self.has_colors:
|
|
591
|
+
self.stdscr.attroff(curses.color_pair(self.COLOR_ERROR))
|
|
592
|
+
|
|
593
|
+
def draw_deployment_footer(self):
|
|
594
|
+
"""Draw footer for deployment screen."""
|
|
595
|
+
h, w = self.stdscr.getmaxyx()
|
|
596
|
+
|
|
597
|
+
# Horizontal line
|
|
598
|
+
self.stdscr.addstr(h - 3, 0, "─" * (w - 1))
|
|
599
|
+
|
|
600
|
+
if self.deploy_status in ("SUCCESS", "FAILED"):
|
|
601
|
+
controls = "[Enter] Exit [c] Copy URL"
|
|
602
|
+
else:
|
|
603
|
+
controls = "[Ctrl+C] Run in background"
|
|
604
|
+
|
|
605
|
+
self.stdscr.addstr(h - 2, (w - len(controls)) // 2, controls)
|
|
606
|
+
|
|
607
|
+
def poll_build_status(self, image_name: str, namespace: str) -> str:
|
|
608
|
+
"""Poll the build status from API.
|
|
609
|
+
|
|
610
|
+
Returns: BUILD_RUNNING, BUILD_SUCCESS, BUILD_FAILED
|
|
611
|
+
"""
|
|
612
|
+
if not self.sdk_client:
|
|
613
|
+
return "BUILD_SUCCESS" # Demo mode
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
images = self.sdk_client.apps.get_images(namespace=namespace)
|
|
617
|
+
for img in images:
|
|
618
|
+
if image_name in img.get('metadata', {}).get('name', ''):
|
|
619
|
+
status = img.get('status', {})
|
|
620
|
+
conditions = status.get('conditions', [])
|
|
621
|
+
|
|
622
|
+
for cond in conditions:
|
|
623
|
+
if cond.get('type') == 'Ready':
|
|
624
|
+
if cond.get('status') == 'True':
|
|
625
|
+
return "BUILD_SUCCESS"
|
|
626
|
+
reason = cond.get('reason', '')
|
|
627
|
+
if 'Failed' in reason or 'Error' in reason:
|
|
628
|
+
self.error_message = cond.get('message', 'Build failed')
|
|
629
|
+
return "BUILD_FAILED"
|
|
630
|
+
|
|
631
|
+
# Still building
|
|
632
|
+
return "BUILD_RUNNING"
|
|
633
|
+
return "BUILD_RUNNING"
|
|
634
|
+
except Exception as e:
|
|
635
|
+
self.error_message = str(e)
|
|
636
|
+
return "BUILD_FAILED"
|
|
637
|
+
|
|
638
|
+
def poll_service_status(self, service_name: str, namespace: str) -> str:
|
|
639
|
+
"""Poll the service status.
|
|
640
|
+
|
|
641
|
+
Returns: SERVICE_PENDING, SERVICE_READY, SERVICE_FAILED
|
|
642
|
+
"""
|
|
643
|
+
# For now, check if build is done and service should be ready
|
|
644
|
+
# In a real implementation, would check Knative service status
|
|
645
|
+
if not self.sdk_client:
|
|
646
|
+
return "SERVICE_READY" # Demo mode
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
images = self.sdk_client.apps.get_images(namespace=namespace)
|
|
650
|
+
for img in images:
|
|
651
|
+
if service_name in img.get('metadata', {}).get('name', '').replace('-image', ''):
|
|
652
|
+
status = img.get('status', {})
|
|
653
|
+
conditions = status.get('conditions', [])
|
|
654
|
+
|
|
655
|
+
for cond in conditions:
|
|
656
|
+
if cond.get('type') == 'Ready' and cond.get('status') == 'True':
|
|
657
|
+
return "SERVICE_READY"
|
|
658
|
+
return "SERVICE_PENDING"
|
|
659
|
+
except Exception:
|
|
660
|
+
return "SERVICE_PENDING"
|
|
661
|
+
|
|
662
|
+
def run_deployment_monitor(self, stdscr) -> bool:
|
|
663
|
+
"""Run the deployment monitoring screen.
|
|
664
|
+
|
|
665
|
+
Returns True if deployment succeeded, False otherwise.
|
|
666
|
+
"""
|
|
667
|
+
self.stdscr = stdscr
|
|
668
|
+
curses.curs_set(0)
|
|
669
|
+
stdscr.clear()
|
|
670
|
+
stdscr.timeout(100) # 100ms for animation
|
|
671
|
+
|
|
672
|
+
self.init_colors()
|
|
673
|
+
self.deploy_start_time = time.time()
|
|
674
|
+
self.deploy_status = "BUILDING"
|
|
675
|
+
self.build_phase = 0
|
|
676
|
+
self.service_state = 0
|
|
677
|
+
|
|
678
|
+
app_name = self.data.get('app_name', 'app')
|
|
679
|
+
namespace = self.data.get('namespace', 'default')
|
|
680
|
+
image_name = f"{app_name}-image"
|
|
681
|
+
|
|
682
|
+
last_poll = 0
|
|
683
|
+
poll_interval = 3.0 # Poll every 3 seconds
|
|
684
|
+
|
|
685
|
+
while True:
|
|
686
|
+
stdscr.clear()
|
|
687
|
+
self.draw_deployment_header()
|
|
688
|
+
self.draw_deployment_progress()
|
|
689
|
+
self.draw_deployment_footer()
|
|
690
|
+
stdscr.refresh()
|
|
691
|
+
|
|
692
|
+
key = stdscr.getch()
|
|
693
|
+
|
|
694
|
+
# Handle exit on success/failure
|
|
695
|
+
if self.deploy_status in ("SUCCESS", "FAILED"):
|
|
696
|
+
if key == ord('\n') or key == ord('q'):
|
|
697
|
+
break
|
|
698
|
+
elif key == ord('c') and self.app_url:
|
|
699
|
+
# Copy URL (would need platform-specific clipboard handling)
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
# Handle Ctrl+C for background
|
|
703
|
+
if key == 3: # Ctrl+C
|
|
704
|
+
return True # Run in background
|
|
705
|
+
|
|
706
|
+
# Poll for status updates
|
|
707
|
+
current_time = time.time()
|
|
708
|
+
if current_time - last_poll >= poll_interval:
|
|
709
|
+
last_poll = current_time
|
|
710
|
+
|
|
711
|
+
if self.deploy_status == "BUILDING":
|
|
712
|
+
build_result = self.poll_build_status(image_name, namespace)
|
|
713
|
+
if build_result == "BUILD_SUCCESS":
|
|
714
|
+
self.build_phase = len(self.BUILD_PHASES)
|
|
715
|
+
self.deploy_status = "DEPLOYING"
|
|
716
|
+
elif build_result == "BUILD_FAILED":
|
|
717
|
+
self.deploy_status = "FAILED"
|
|
718
|
+
else:
|
|
719
|
+
# Simulate build phase progress
|
|
720
|
+
elapsed = current_time - self.deploy_start_time
|
|
721
|
+
self.build_phase = min(int(elapsed / 15), len(self.BUILD_PHASES) - 1)
|
|
722
|
+
|
|
723
|
+
elif self.deploy_status == "DEPLOYING":
|
|
724
|
+
svc_result = self.poll_service_status(app_name, namespace)
|
|
725
|
+
if svc_result == "SERVICE_READY":
|
|
726
|
+
self.service_state = len(self.SERVICE_STATES)
|
|
727
|
+
self.deploy_status = "SUCCESS"
|
|
728
|
+
self.app_url = f"https://{app_name}.{namespace}.apps.clue2.app"
|
|
729
|
+
elif svc_result == "SERVICE_FAILED":
|
|
730
|
+
self.deploy_status = "FAILED"
|
|
731
|
+
else:
|
|
732
|
+
# Simulate service state progress
|
|
733
|
+
elapsed = current_time - self.deploy_start_time
|
|
734
|
+
build_time = len(self.BUILD_PHASES) * 15
|
|
735
|
+
deploy_elapsed = elapsed - build_time
|
|
736
|
+
self.service_state = min(int(deploy_elapsed / 5), len(self.SERVICE_STATES) - 1)
|
|
737
|
+
|
|
738
|
+
# Timeout check (10 minutes)
|
|
739
|
+
if time.time() - self.deploy_start_time > 600:
|
|
740
|
+
self.deploy_status = "FAILED"
|
|
741
|
+
self.error_message = "Deployment timed out"
|
|
742
|
+
|
|
743
|
+
return self.deploy_status == "SUCCESS"
|
|
744
|
+
|
|
745
|
+
def run_curses(self, stdscr):
|
|
746
|
+
"""Main curses loop with animation."""
|
|
747
|
+
self.stdscr = stdscr
|
|
748
|
+
curses.curs_set(0) # Hide cursor
|
|
749
|
+
stdscr.clear()
|
|
750
|
+
stdscr.timeout(100) # 100ms timeout for animation
|
|
751
|
+
|
|
752
|
+
# Initialize colors
|
|
753
|
+
self.init_colors()
|
|
754
|
+
|
|
755
|
+
running = True
|
|
756
|
+
while running:
|
|
757
|
+
stdscr.clear()
|
|
758
|
+
self.draw_header()
|
|
759
|
+
self.draw_step()
|
|
760
|
+
self.draw_footer()
|
|
761
|
+
stdscr.refresh()
|
|
762
|
+
|
|
763
|
+
key = stdscr.getch()
|
|
764
|
+
if key != -1: # -1 means timeout (no key pressed)
|
|
765
|
+
running = self.handle_input(key)
|
|
766
|
+
|
|
767
|
+
return self.data if self.data.get('confirmed') else None
|
|
768
|
+
|
|
769
|
+
def run(self) -> Optional[Dict[str, Any]]:
|
|
770
|
+
"""Run the wizard."""
|
|
771
|
+
return curses.wrapper(self.run_curses)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def run_curses_wizard(projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None) -> Optional[Dict[str, Any]]:
|
|
775
|
+
"""
|
|
776
|
+
Run the curses-based deploy wizard.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
projects: List of available projects (unused, for API compatibility)
|
|
780
|
+
deploy_func: Function to call for deployment
|
|
781
|
+
sdk_client: SDK client for monitoring deployment progress
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
Wizard data if completed, None if cancelled
|
|
785
|
+
"""
|
|
786
|
+
wizard = CursesWizard(projects=projects, deploy_func=deploy_func, sdk_client=sdk_client)
|
|
787
|
+
result = wizard.run()
|
|
788
|
+
|
|
789
|
+
if result and result.get('confirmed'):
|
|
790
|
+
# Call deploy function if provided
|
|
791
|
+
if deploy_func:
|
|
792
|
+
try:
|
|
793
|
+
deploy_func(result)
|
|
794
|
+
except Exception as e:
|
|
795
|
+
print(f"Deployment initiation error: {e}")
|
|
796
|
+
return result
|
|
797
|
+
|
|
798
|
+
# Run deployment monitor
|
|
799
|
+
success = curses.wrapper(wizard.run_deployment_monitor)
|
|
800
|
+
|
|
801
|
+
if success:
|
|
802
|
+
result['app_url'] = wizard.app_url
|
|
803
|
+
result['deploy_status'] = 'SUCCESS'
|
|
804
|
+
else:
|
|
805
|
+
result['deploy_status'] = 'FAILED'
|
|
806
|
+
result['error'] = wizard.error_message
|
|
807
|
+
|
|
808
|
+
return result
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def run_curses_wizard_demo() -> Optional[Dict[str, Any]]:
|
|
812
|
+
"""Run the wizard in demo mode (no actual deployment)."""
|
|
813
|
+
wizard = CursesWizard(deploy_func=None, sdk_client=None)
|
|
814
|
+
result = wizard.run()
|
|
815
|
+
|
|
816
|
+
if result and result.get('confirmed'):
|
|
817
|
+
# Run deployment monitor in demo mode (simulated)
|
|
818
|
+
curses.wrapper(wizard.run_deployment_monitor)
|
|
819
|
+
result['app_url'] = wizard.app_url
|
|
820
|
+
result['deploy_status'] = wizard.deploy_status
|
|
821
|
+
|
|
822
|
+
return result
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
if __name__ == "__main__":
|
|
826
|
+
import sys
|
|
827
|
+
|
|
828
|
+
if '--demo' in sys.argv:
|
|
829
|
+
result = run_curses_wizard_demo()
|
|
830
|
+
else:
|
|
831
|
+
result = run_curses_wizard()
|
|
832
|
+
|
|
833
|
+
if result:
|
|
834
|
+
print(f"\nWizard completed!")
|
|
835
|
+
if result.get('app_url'):
|
|
836
|
+
print(f"Application URL: {result['app_url']}")
|
|
837
|
+
print(f"Status: {result.get('deploy_status', 'N/A')}")
|
|
838
|
+
else:
|
|
839
|
+
print("Wizard cancelled")
|