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.
@@ -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")