signalwire-agents 1.0.11__py3-none-any.whl → 1.0.13__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,1629 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SignalWire Agent Dokku Deployment Tool
4
+
5
+ CLI tool for deploying SignalWire agents to Dokku with support for:
6
+ - Simple git push deployment
7
+ - Full CI/CD with GitHub Actions
8
+ - Service provisioning (PostgreSQL, Redis)
9
+ - Preview environments for PRs
10
+
11
+ Usage:
12
+ sw-agent-dokku init myagent # Simple mode
13
+ sw-agent-dokku init myagent --cicd # With GitHub Actions CI/CD
14
+ sw-agent-dokku deploy # Deploy current directory
15
+ sw-agent-dokku logs # Tail logs
16
+ sw-agent-dokku config set KEY=value # Set environment variables
17
+ sw-agent-dokku scale web=2 # Scale processes
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import subprocess
23
+ import secrets
24
+ import argparse
25
+ import shutil
26
+ from pathlib import Path
27
+ from typing import Optional, Dict, List, Any
28
+
29
+
30
+ # =============================================================================
31
+ # ANSI Colors
32
+ # =============================================================================
33
+
34
+ class Colors:
35
+ RED = '\033[0;31m'
36
+ GREEN = '\033[0;32m'
37
+ YELLOW = '\033[1;33m'
38
+ BLUE = '\033[0;34m'
39
+ CYAN = '\033[0;36m'
40
+ MAGENTA = '\033[0;35m'
41
+ BOLD = '\033[1m'
42
+ DIM = '\033[2m'
43
+ NC = '\033[0m'
44
+
45
+
46
+ def print_step(msg: str):
47
+ print(f"{Colors.BLUE}==>{Colors.NC} {msg}")
48
+
49
+
50
+ def print_success(msg: str):
51
+ print(f"{Colors.GREEN}✓{Colors.NC} {msg}")
52
+
53
+
54
+ def print_warning(msg: str):
55
+ print(f"{Colors.YELLOW}!{Colors.NC} {msg}")
56
+
57
+
58
+ def print_error(msg: str):
59
+ print(f"{Colors.RED}✗{Colors.NC} {msg}")
60
+
61
+
62
+ def print_header(msg: str):
63
+ print(f"\n{Colors.BOLD}{Colors.CYAN}{msg}{Colors.NC}")
64
+
65
+
66
+ def prompt(question: str, default: str = "") -> str:
67
+ if default:
68
+ result = input(f"{question} [{default}]: ").strip()
69
+ return result if result else default
70
+ return input(f"{question}: ").strip()
71
+
72
+
73
+ def prompt_yes_no(question: str, default: bool = True) -> bool:
74
+ hint = "Y/n" if default else "y/N"
75
+ result = input(f"{question} [{hint}]: ").strip().lower()
76
+ if not result:
77
+ return default
78
+ return result in ('y', 'yes')
79
+
80
+
81
+ def generate_password(length: int = 32) -> str:
82
+ return secrets.token_urlsafe(length)[:length]
83
+
84
+
85
+ # =============================================================================
86
+ # Templates - Core Files
87
+ # =============================================================================
88
+
89
+ PROCFILE_TEMPLATE = """web: gunicorn app:app --bind 0.0.0.0:$PORT --workers 2 --worker-class uvicorn.workers.UvicornWorker
90
+ """
91
+
92
+ RUNTIME_TEMPLATE = """python-3.11
93
+ """
94
+
95
+ REQUIREMENTS_TEMPLATE = """signalwire-agents>=1.0.13
96
+ gunicorn>=21.0.0
97
+ uvicorn>=0.24.0
98
+ python-dotenv>=1.0.0
99
+ """
100
+
101
+ CHECKS_TEMPLATE = """WAIT=5
102
+ TIMEOUT=30
103
+ ATTEMPTS=5
104
+
105
+ /health
106
+ """
107
+
108
+ GITIGNORE_TEMPLATE = """# Environment
109
+ .env
110
+ .venv/
111
+ venv/
112
+ __pycache__/
113
+ *.pyc
114
+ *.pyo
115
+
116
+ # IDE
117
+ .vscode/
118
+ .idea/
119
+ *.swp
120
+ *.swo
121
+
122
+ # Testing
123
+ .pytest_cache/
124
+ .coverage
125
+ htmlcov/
126
+
127
+ # Build
128
+ dist/
129
+ build/
130
+ *.egg-info/
131
+
132
+ # Logs
133
+ *.log
134
+
135
+ # OS
136
+ .DS_Store
137
+ Thumbs.db
138
+ """
139
+
140
+ ENV_EXAMPLE_TEMPLATE = """# SignalWire Agent Configuration
141
+ SWML_BASIC_AUTH_USER=admin
142
+ SWML_BASIC_AUTH_PASSWORD=your-secure-password
143
+
144
+ # App Configuration
145
+ APP_ENV=production
146
+ APP_NAME={app_name}
147
+
148
+ # Optional: External Services
149
+ # DATABASE_URL=postgres://user:pass@host:5432/db
150
+ # REDIS_URL=redis://host:6379
151
+ """
152
+
153
+ APP_TEMPLATE = '''#!/usr/bin/env python3
154
+ """
155
+ {agent_name} - SignalWire AI Agent
156
+
157
+ Deployed to Dokku with automatic health checks and SWAIG support.
158
+ """
159
+
160
+ import os
161
+ from dotenv import load_dotenv
162
+ from signalwire_agents import AgentBase, SwaigFunctionResult
163
+
164
+ # Load environment variables from .env file
165
+ load_dotenv()
166
+
167
+
168
+ class {agent_class}(AgentBase):
169
+ """{agent_name} agent for Dokku deployment."""
170
+
171
+ def __init__(self):
172
+ super().__init__(name="{agent_slug}")
173
+
174
+ self._configure_prompts()
175
+ self.add_language("English", "en-US", "rime.spore")
176
+ self._setup_functions()
177
+
178
+ def _configure_prompts(self):
179
+ self.prompt_add_section(
180
+ "Role",
181
+ "You are a helpful AI assistant deployed on Dokku."
182
+ )
183
+
184
+ self.prompt_add_section(
185
+ "Guidelines",
186
+ bullets=[
187
+ "Be professional and courteous",
188
+ "Ask clarifying questions when needed",
189
+ "Keep responses concise and helpful"
190
+ ]
191
+ )
192
+
193
+ def _setup_functions(self):
194
+ @self.tool(
195
+ description="Get information about a topic",
196
+ parameters={{
197
+ "type": "object",
198
+ "properties": {{
199
+ "topic": {{
200
+ "type": "string",
201
+ "description": "The topic to get information about"
202
+ }}
203
+ }},
204
+ "required": ["topic"]
205
+ }}
206
+ )
207
+ def get_info(args, raw_data):
208
+ topic = args.get("topic", "")
209
+ return SwaigFunctionResult(
210
+ f"Information about {{topic}}: This is a placeholder response."
211
+ )
212
+
213
+ @self.tool(description="Get deployment information")
214
+ def get_deployment_info(args, raw_data):
215
+ app_name = os.getenv("APP_NAME", "unknown")
216
+ app_env = os.getenv("APP_ENV", "unknown")
217
+
218
+ return SwaigFunctionResult(
219
+ f"Running on Dokku. App: {{app_name}}, Environment: {{app_env}}."
220
+ )
221
+
222
+
223
+ # Create agent instance
224
+ agent = {agent_class}()
225
+
226
+ # Expose the FastAPI app for gunicorn/uvicorn
227
+ app = agent.get_app()
228
+
229
+ if __name__ == "__main__":
230
+ agent.run()
231
+ '''
232
+
233
+ APP_TEMPLATE_WITH_WEB = '''#!/usr/bin/env python3
234
+ """
235
+ {agent_name} - SignalWire AI Agent
236
+
237
+ Deployed to Dokku with automatic health checks, SWAIG support, and web interface.
238
+ """
239
+
240
+ import os
241
+ from pathlib import Path
242
+ from dotenv import load_dotenv
243
+ from signalwire_agents import AgentBase, AgentServer, SwaigFunctionResult
244
+
245
+ # Load environment variables from .env file
246
+ load_dotenv()
247
+
248
+
249
+ class {agent_class}(AgentBase):
250
+ """{agent_name} agent for Dokku deployment."""
251
+
252
+ def __init__(self):
253
+ super().__init__(name="{agent_slug}", route="/swml")
254
+
255
+ self._configure_prompts()
256
+ self.add_language("English", "en-US", "rime.spore")
257
+ self._setup_functions()
258
+
259
+ def _configure_prompts(self):
260
+ self.prompt_add_section(
261
+ "Role",
262
+ "You are a helpful AI assistant deployed on Dokku."
263
+ )
264
+
265
+ self.prompt_add_section(
266
+ "Guidelines",
267
+ bullets=[
268
+ "Be professional and courteous",
269
+ "Ask clarifying questions when needed",
270
+ "Keep responses concise and helpful"
271
+ ]
272
+ )
273
+
274
+ def _setup_functions(self):
275
+ @self.tool(
276
+ description="Get information about a topic",
277
+ parameters={{
278
+ "type": "object",
279
+ "properties": {{
280
+ "topic": {{
281
+ "type": "string",
282
+ "description": "The topic to get information about"
283
+ }}
284
+ }},
285
+ "required": ["topic"]
286
+ }}
287
+ )
288
+ def get_info(args, raw_data):
289
+ topic = args.get("topic", "")
290
+ return SwaigFunctionResult(
291
+ f"Information about {{topic}}: This is a placeholder response."
292
+ )
293
+
294
+ @self.tool(description="Get deployment information")
295
+ def get_deployment_info(args, raw_data):
296
+ app_name = os.getenv("APP_NAME", "unknown")
297
+ app_env = os.getenv("APP_ENV", "unknown")
298
+
299
+ return SwaigFunctionResult(
300
+ f"Running on Dokku. App: {{app_name}}, Environment: {{app_env}}."
301
+ )
302
+
303
+
304
+ # Create server and register agent
305
+ server = AgentServer(host="0.0.0.0", port=int(os.getenv("PORT", 3000)))
306
+ server.register({agent_class}())
307
+
308
+ # Serve static files from web/ directory (no auth required)
309
+ web_dir = Path(__file__).parent / "web"
310
+ if web_dir.exists():
311
+ server.serve_static_files(str(web_dir))
312
+
313
+ # Expose the ASGI app for gunicorn
314
+ app = server.app
315
+
316
+ # Register catch-all for static files (needed for gunicorn since _run_server() isn't called)
317
+ from fastapi import Request, HTTPException
318
+ from fastapi.responses import FileResponse, RedirectResponse
319
+ import mimetypes
320
+
321
+ # Redirect /swml to /swml/ (trailing slash required by FastAPI router)
322
+ @app.api_route("/swml", methods=["GET", "POST"])
323
+ async def swml_redirect():
324
+ return RedirectResponse(url="/swml/", status_code=307)
325
+
326
+ @app.get("/{{full_path:path}}")
327
+ async def serve_static(request: Request, full_path: str):
328
+ """Serve static files from web/ directory"""
329
+ if not web_dir.exists():
330
+ raise HTTPException(status_code=404, detail="Not Found")
331
+
332
+ # Handle root path
333
+ if not full_path or full_path == "/":
334
+ full_path = "index.html"
335
+
336
+ file_path = web_dir / full_path
337
+
338
+ # Security: prevent directory traversal
339
+ try:
340
+ file_path = file_path.resolve()
341
+ if not str(file_path).startswith(str(web_dir.resolve())):
342
+ raise HTTPException(status_code=404, detail="Not Found")
343
+ except Exception:
344
+ raise HTTPException(status_code=404, detail="Not Found")
345
+
346
+ if file_path.exists() and file_path.is_file():
347
+ media_type, _ = mimetypes.guess_type(str(file_path))
348
+ return FileResponse(file_path, media_type=media_type)
349
+
350
+ # Try index.html for directory paths
351
+ if (web_dir / full_path / "index.html").exists():
352
+ return FileResponse(web_dir / full_path / "index.html", media_type="text/html")
353
+
354
+ raise HTTPException(status_code=404, detail="Not Found")
355
+
356
+ if __name__ == "__main__":
357
+ server.run()
358
+ '''
359
+
360
+ WEB_INDEX_TEMPLATE = '''<!DOCTYPE html>
361
+ <html lang="en">
362
+ <head>
363
+ <meta charset="UTF-8">
364
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
365
+ <title>{agent_name}</title>
366
+ <style>
367
+ :root {{
368
+ --primary: #2563eb;
369
+ --primary-dark: #1d4ed8;
370
+ --bg: #f8fafc;
371
+ --card: #ffffff;
372
+ --text: #1e293b;
373
+ --text-muted: #64748b;
374
+ --border: #e2e8f0;
375
+ }}
376
+ * {{
377
+ box-sizing: border-box;
378
+ margin: 0;
379
+ padding: 0;
380
+ }}
381
+ body {{
382
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
383
+ background: var(--bg);
384
+ color: var(--text);
385
+ min-height: 100vh;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ padding: 2rem;
390
+ }}
391
+ .container {{
392
+ max-width: 600px;
393
+ width: 100%;
394
+ }}
395
+ .card {{
396
+ background: var(--card);
397
+ border-radius: 1rem;
398
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
399
+ padding: 2rem;
400
+ text-align: center;
401
+ }}
402
+ .logo {{
403
+ width: 80px;
404
+ height: 80px;
405
+ background: var(--primary);
406
+ border-radius: 1rem;
407
+ display: flex;
408
+ align-items: center;
409
+ justify-content: center;
410
+ margin: 0 auto 1.5rem;
411
+ }}
412
+ .logo svg {{
413
+ width: 48px;
414
+ height: 48px;
415
+ fill: white;
416
+ }}
417
+ h1 {{
418
+ font-size: 1.75rem;
419
+ margin-bottom: 0.5rem;
420
+ }}
421
+ .subtitle {{
422
+ color: var(--text-muted);
423
+ margin-bottom: 2rem;
424
+ }}
425
+ .status {{
426
+ display: inline-flex;
427
+ align-items: center;
428
+ gap: 0.5rem;
429
+ padding: 0.5rem 1rem;
430
+ background: #dcfce7;
431
+ color: #166534;
432
+ border-radius: 2rem;
433
+ font-size: 0.875rem;
434
+ font-weight: 500;
435
+ }}
436
+ .status::before {{
437
+ content: '';
438
+ width: 8px;
439
+ height: 8px;
440
+ background: #22c55e;
441
+ border-radius: 50%;
442
+ }}
443
+ .endpoints {{
444
+ margin-top: 2rem;
445
+ text-align: left;
446
+ border-top: 1px solid var(--border);
447
+ padding-top: 1.5rem;
448
+ }}
449
+ .endpoints h2 {{
450
+ font-size: 0.875rem;
451
+ text-transform: uppercase;
452
+ letter-spacing: 0.05em;
453
+ color: var(--text-muted);
454
+ margin-bottom: 1rem;
455
+ }}
456
+ .endpoint {{
457
+ display: flex;
458
+ align-items: center;
459
+ padding: 0.75rem 0;
460
+ border-bottom: 1px solid var(--border);
461
+ }}
462
+ .endpoint:last-child {{
463
+ border-bottom: none;
464
+ }}
465
+ .method {{
466
+ font-size: 0.75rem;
467
+ font-weight: 600;
468
+ padding: 0.25rem 0.5rem;
469
+ border-radius: 0.25rem;
470
+ margin-right: 1rem;
471
+ min-width: 50px;
472
+ text-align: center;
473
+ }}
474
+ .method.get {{
475
+ background: #dbeafe;
476
+ color: #1d4ed8;
477
+ }}
478
+ .method.post {{
479
+ background: #dcfce7;
480
+ color: #166534;
481
+ }}
482
+ .path {{
483
+ font-family: monospace;
484
+ color: var(--text);
485
+ }}
486
+ .desc {{
487
+ margin-left: auto;
488
+ color: var(--text-muted);
489
+ font-size: 0.875rem;
490
+ }}
491
+ </style>
492
+ </head>
493
+ <body>
494
+ <div class="container">
495
+ <div class="card">
496
+ <div class="logo">
497
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
498
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
499
+ </svg>
500
+ </div>
501
+ <h1>{agent_name}</h1>
502
+ <p class="subtitle">SignalWire AI Agent</p>
503
+ <span class="status">Running on Dokku</span>
504
+
505
+ <div class="endpoints">
506
+ <h2>API Endpoints</h2>
507
+ <div class="endpoint">
508
+ <span class="method get">GET</span>
509
+ <span class="path">/health</span>
510
+ <span class="desc">Health check</span>
511
+ </div>
512
+ <div class="endpoint">
513
+ <span class="method get">GET</span>
514
+ <span class="path">/ready</span>
515
+ <span class="desc">Readiness check</span>
516
+ </div>
517
+ <div class="endpoint">
518
+ <span class="method post">POST</span>
519
+ <span class="path">/swml</span>
520
+ <span class="desc">SWML endpoint</span>
521
+ </div>
522
+ <div class="endpoint">
523
+ <span class="method post">POST</span>
524
+ <span class="path">/swml/swaig</span>
525
+ <span class="desc">SWAIG functions</span>
526
+ </div>
527
+ </div>
528
+ </div>
529
+ </div>
530
+ </body>
531
+ </html>
532
+ '''
533
+
534
+ APP_JSON_TEMPLATE = '''{{
535
+ "name": "{app_name}",
536
+ "description": "SignalWire AI Agent",
537
+ "keywords": ["signalwire", "ai", "agent", "python"],
538
+ "env": {{
539
+ "APP_ENV": {{
540
+ "description": "Application environment",
541
+ "value": "production"
542
+ }},
543
+ "SWML_BASIC_AUTH_USER": {{
544
+ "description": "Basic auth username for SWML endpoints",
545
+ "required": true
546
+ }},
547
+ "SWML_BASIC_AUTH_PASSWORD": {{
548
+ "description": "Basic auth password for SWML endpoints",
549
+ "required": true
550
+ }}
551
+ }},
552
+ "formation": {{
553
+ "web": {{
554
+ "quantity": 1
555
+ }}
556
+ }},
557
+ "buildpacks": [
558
+ {{
559
+ "url": "heroku/python"
560
+ }}
561
+ ],
562
+ "healthchecks": {{
563
+ "web": [
564
+ {{
565
+ "type": "startup",
566
+ "name": "port listening",
567
+ "listening": true,
568
+ "attempts": 10,
569
+ "wait": 5,
570
+ "timeout": 60
571
+ }}
572
+ ]
573
+ }}
574
+ }}
575
+ '''
576
+
577
+ # =============================================================================
578
+ # Templates - Simple Mode
579
+ # =============================================================================
580
+
581
+ DEPLOY_SCRIPT_TEMPLATE = '''#!/bin/bash
582
+ # Dokku deployment helper for {app_name}
583
+ set -e
584
+
585
+ APP_NAME="${{1:-{app_name}}}"
586
+ DOKKU_HOST="${{2:-{dokku_host}}}"
587
+
588
+ echo "═══════════════════════════════════════════════════════════"
589
+ echo " Deploying $APP_NAME to $DOKKU_HOST"
590
+ echo "═══════════════════════════════════════════════════════════"
591
+
592
+ # Initialize git if needed
593
+ if [ ! -d .git ]; then
594
+ echo "→ Initializing git repository..."
595
+ git init
596
+ git add .
597
+ git commit -m "Initial commit"
598
+ fi
599
+
600
+ # Create app if it doesn't exist
601
+ echo "→ Creating app (if not exists)..."
602
+ ssh dokku@$DOKKU_HOST apps:create $APP_NAME 2>/dev/null || true
603
+
604
+ # Set environment variables
605
+ echo "→ Setting environment variables..."
606
+ AUTH_PASS=$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)
607
+ ssh dokku@$DOKKU_HOST config:set --no-restart $APP_NAME \\
608
+ APP_ENV=production \\
609
+ APP_NAME=$APP_NAME \\
610
+ SWML_BASIC_AUTH_USER=admin \\
611
+ SWML_BASIC_AUTH_PASSWORD=$AUTH_PASS
612
+
613
+ # Add dokku remote
614
+ echo "→ Configuring git remote..."
615
+ git remote add dokku dokku@$DOKKU_HOST:$APP_NAME 2>/dev/null || \\
616
+ git remote set-url dokku dokku@$DOKKU_HOST:$APP_NAME
617
+
618
+ # Deploy
619
+ echo "→ Pushing to Dokku..."
620
+ git push dokku main --force
621
+
622
+ # Enable SSL
623
+ echo "→ Enabling Let's Encrypt SSL..."
624
+ ssh dokku@$DOKKU_HOST letsencrypt:enable $APP_NAME 2>/dev/null || \\
625
+ echo " (SSL setup may require manual configuration)"
626
+
627
+ echo ""
628
+ echo "═══════════════════════════════════════════════════════════"
629
+ echo " ✅ Deployment complete!"
630
+ echo ""
631
+ echo " 🌐 URL: https://$APP_NAME.$DOKKU_HOST"
632
+ echo " 🔑 Auth: admin / $AUTH_PASS"
633
+ echo ""
634
+ echo " Configure SignalWire phone number SWML URL to:"
635
+ echo " https://admin:$AUTH_PASS@$APP_NAME.$DOKKU_HOST/{route}"
636
+ echo "═══════════════════════════════════════════════════════════"
637
+ '''
638
+
639
+ README_SIMPLE_TEMPLATE = '''# {app_name}
640
+
641
+ A SignalWire AI Agent deployed to Dokku.
642
+
643
+ ## Quick Deploy
644
+
645
+ ```bash
646
+ ./deploy.sh {app_name} {dokku_host}
647
+ ```
648
+
649
+ ## Manual Deployment
650
+
651
+ 1. **Create the app:**
652
+ ```bash
653
+ ssh dokku@{dokku_host} apps:create {app_name}
654
+ ```
655
+
656
+ 2. **Set environment variables:**
657
+ ```bash
658
+ ssh dokku@{dokku_host} config:set {app_name} \\
659
+ SWML_BASIC_AUTH_USER=admin \\
660
+ SWML_BASIC_AUTH_PASSWORD=secure-password \\
661
+ APP_ENV=production
662
+ ```
663
+
664
+ 3. **Add git remote and deploy:**
665
+ ```bash
666
+ git remote add dokku dokku@{dokku_host}:{app_name}
667
+ git push dokku main
668
+ ```
669
+
670
+ 4. **Enable SSL:**
671
+ ```bash
672
+ ssh dokku@{dokku_host} letsencrypt:enable {app_name}
673
+ ```
674
+
675
+ ## Usage
676
+
677
+ Your agent is available at: `https://{app_name}.{dokku_host_domain}`
678
+
679
+ Configure your SignalWire phone number:
680
+ - **SWML URL:** `https://{app_name}.{dokku_host_domain}/{route}`
681
+ - **Auth:** Basic auth with your configured credentials
682
+
683
+ ## Useful Commands
684
+
685
+ ```bash
686
+ # View logs
687
+ ssh dokku@{dokku_host} logs {app_name} -t
688
+
689
+ # Restart app
690
+ ssh dokku@{dokku_host} ps:restart {app_name}
691
+
692
+ # View environment variables
693
+ ssh dokku@{dokku_host} config:show {app_name}
694
+
695
+ # Scale workers
696
+ ssh dokku@{dokku_host} ps:scale {app_name} web=2
697
+
698
+ # Rollback to previous release
699
+ ssh dokku@{dokku_host} releases:rollback {app_name}
700
+ ```
701
+
702
+ ## Local Development
703
+
704
+ ```bash
705
+ pip install -r requirements.txt
706
+ uvicorn app:app --reload --port 8080
707
+ ```
708
+
709
+ Test with swaig-test:
710
+ ```bash
711
+ swaig-test app.py --list-tools
712
+ ```
713
+ '''
714
+
715
+ # =============================================================================
716
+ # Templates - CI/CD Mode
717
+ # =============================================================================
718
+
719
+ DEPLOY_WORKFLOW_TEMPLATE = '''# Auto-deploy to Dokku on push
720
+ name: Deploy
721
+
722
+ on:
723
+ workflow_dispatch:
724
+ push:
725
+ branches: [main, staging, develop]
726
+
727
+ concurrency:
728
+ group: deploy-${{{{ github.ref }}}}
729
+ cancel-in-progress: true
730
+
731
+ jobs:
732
+ deploy:
733
+ runs-on: ubuntu-latest
734
+ environment: ${{{{ github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'development' }}}}
735
+ env:
736
+ BASE_APP_NAME: ${{{{ github.event.repository.name }}}}
737
+ steps:
738
+ - uses: actions/checkout@v4
739
+ with:
740
+ fetch-depth: 0
741
+
742
+ - name: Set variables
743
+ id: vars
744
+ run: |
745
+ BRANCH="${{GITHUB_REF#refs/heads/}}"
746
+ case "$BRANCH" in
747
+ main) APP="${{BASE_APP_NAME}}"; ENV="production" ;;
748
+ staging) APP="${{BASE_APP_NAME}}-staging"; ENV="staging" ;;
749
+ develop) APP="${{BASE_APP_NAME}}-dev"; ENV="development" ;;
750
+ *) APP="${{BASE_APP_NAME}}"; ENV="production" ;;
751
+ esac
752
+ echo "app_name=$APP" >> $GITHUB_OUTPUT
753
+ echo "environment=$ENV" >> $GITHUB_OUTPUT
754
+
755
+ - name: Setup SSH
756
+ run: |
757
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
758
+ echo "${{{{ secrets.DOKKU_SSH_PRIVATE_KEY }}}}" > ~/.ssh/key && chmod 600 ~/.ssh/key
759
+ ssh-keyscan -H ${{{{ secrets.DOKKU_HOST }}}} >> ~/.ssh/known_hosts
760
+ echo -e "Host dokku\\n HostName ${{{{ secrets.DOKKU_HOST }}}}\\n User dokku\\n IdentityFile ~/.ssh/key" > ~/.ssh/config
761
+
762
+ - name: Create app
763
+ run: |
764
+ APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
765
+ ssh dokku apps:exists $APP_NAME 2>/dev/null || ssh dokku apps:create $APP_NAME
766
+
767
+ - name: Unlock app
768
+ run: |
769
+ APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
770
+ ssh dokku apps:unlock $APP_NAME 2>/dev/null || true
771
+
772
+ - name: Configure
773
+ run: |
774
+ APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
775
+ DOMAIN="${{APP_NAME}}.${{{{ secrets.BASE_DOMAIN }}}}"
776
+ ssh dokku config:set --no-restart $APP_NAME \\
777
+ APP_ENV="${{{{ steps.vars.outputs.environment }}}}" \\
778
+ APP_URL="https://${{DOMAIN}}" \\
779
+ SWML_BASIC_AUTH_USER="${{{{ secrets.SWML_BASIC_AUTH_USER }}}}" \\
780
+ SWML_BASIC_AUTH_PASSWORD="${{{{ secrets.SWML_BASIC_AUTH_PASSWORD }}}}"
781
+ ssh dokku domains:clear $APP_NAME 2>/dev/null || true
782
+ ssh dokku domains:add $APP_NAME $DOMAIN
783
+
784
+ - name: Deploy
785
+ run: |
786
+ APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
787
+ git remote add dokku dokku@${{{{ secrets.DOKKU_HOST }}}}:$APP_NAME 2>/dev/null || true
788
+ GIT_SSH_COMMAND="ssh -i ~/.ssh/key" git push dokku HEAD:main -f
789
+
790
+ - name: SSL
791
+ run: |
792
+ APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
793
+ echo "Checking SSL status..."
794
+ SSL_STATUS=$(ssh dokku letsencrypt:active $APP_NAME 2>/dev/null || echo "error")
795
+ echo "SSL status: $SSL_STATUS"
796
+ if [ "$SSL_STATUS" = "true" ]; then
797
+ echo "SSL already active"
798
+ else
799
+ echo "Enabling SSL..."
800
+ ssh dokku letsencrypt:enable $APP_NAME 2>&1 || echo "SSL enable failed"
801
+ fi
802
+
803
+ - name: Verify
804
+ run: |
805
+ APP_NAME="${{{{ steps.vars.outputs.app_name }}}}"
806
+ DOMAIN="${{APP_NAME}}.${{{{ secrets.BASE_DOMAIN }}}}"
807
+ sleep 10
808
+ curl -sf "https://${{DOMAIN}}/health" && echo "HTTPS OK: https://${{DOMAIN}}" || curl -sf "http://${{DOMAIN}}/health" && echo "HTTP only: http://${{DOMAIN}}" || echo "Check logs"
809
+ '''
810
+
811
+ PREVIEW_WORKFLOW_TEMPLATE = '''# Preview environments for pull requests
812
+ name: Preview
813
+
814
+ on:
815
+ pull_request:
816
+ types: [opened, synchronize, reopened, closed]
817
+
818
+ concurrency:
819
+ group: preview-${{{{ github.event.pull_request.number }}}}
820
+
821
+ env:
822
+ APP_NAME: ${{{{ github.event.repository.name }}}}-pr-${{{{ github.event.pull_request.number }}}}
823
+
824
+ jobs:
825
+ deploy:
826
+ if: github.event.action != 'closed'
827
+ runs-on: ubuntu-latest
828
+ environment: preview
829
+ steps:
830
+ - uses: actions/checkout@v4
831
+ with:
832
+ fetch-depth: 0
833
+
834
+ - name: Setup SSH
835
+ run: |
836
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
837
+ echo "${{{{ secrets.DOKKU_SSH_PRIVATE_KEY }}}}" > ~/.ssh/key && chmod 600 ~/.ssh/key
838
+ ssh-keyscan -H ${{{{ secrets.DOKKU_HOST }}}} >> ~/.ssh/known_hosts
839
+
840
+ - name: Deploy preview
841
+ run: |
842
+ DOMAIN="${{{{ env.APP_NAME }}}}.${{{{ secrets.BASE_DOMAIN }}}}"
843
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} apps:exists $APP_NAME 2>/dev/null || \\
844
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} apps:create $APP_NAME
845
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} config:set --no-restart $APP_NAME APP_ENV=preview APP_URL="https://$DOMAIN"
846
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} domains:clear $APP_NAME 2>/dev/null || true
847
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} domains:add $APP_NAME $DOMAIN
848
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} resource:limit $APP_NAME --memory 256m || true
849
+ git remote add dokku dokku@${{{{ secrets.DOKKU_HOST }}}}:$APP_NAME 2>/dev/null || true
850
+ GIT_SSH_COMMAND="ssh -i ~/.ssh/key" git push dokku HEAD:main -f
851
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} letsencrypt:enable $APP_NAME || true
852
+
853
+ - name: Comment URL
854
+ uses: actions/github-script@v7
855
+ with:
856
+ script: |
857
+ const url = `https://${{{{ env.APP_NAME }}}}.${{{{ secrets.BASE_DOMAIN }}}}`;
858
+ const body = `## 🚀 Preview\\n\\n✅ Deployed: [${{url}}](${{url}})\\n\\n<sub>Auto-destroyed on PR close</sub>`;
859
+ const comments = await github.rest.issues.listComments({{owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number}});
860
+ const bot = comments.data.find(c => c.user.type === 'Bot' && c.body.includes('Preview'));
861
+ if (bot) await github.rest.issues.updateComment({{owner: context.repo.owner, repo: context.repo.repo, comment_id: bot.id, body}});
862
+ else await github.rest.issues.createComment({{owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body}});
863
+
864
+ cleanup:
865
+ if: github.event.action == 'closed'
866
+ runs-on: ubuntu-latest
867
+ steps:
868
+ - name: Destroy preview
869
+ run: |
870
+ mkdir -p ~/.ssh
871
+ echo "${{{{ secrets.DOKKU_SSH_PRIVATE_KEY }}}}" > ~/.ssh/key && chmod 600 ~/.ssh/key
872
+ ssh-keyscan -H ${{{{ secrets.DOKKU_HOST }}}} >> ~/.ssh/known_hosts
873
+ ssh -i ~/.ssh/key dokku@${{{{ secrets.DOKKU_HOST }}}} apps:destroy ${{{{ env.APP_NAME }}}} --force || true
874
+ '''
875
+
876
+ DOKKU_CONFIG_TEMPLATE = '''# ═══════════════════════════════════════════════════════════════════════════════
877
+ # Dokku App Configuration
878
+ # ═══════════════════════════════════════════════════════════════════════════════
879
+ #
880
+ # Configuration for your Dokku app deployment.
881
+ # These settings are applied during the deployment workflow.
882
+ #
883
+ # ═══════════════════════════════════════════════════════════════════════════════
884
+
885
+ # ─────────────────────────────────────────────────────────────────────────────
886
+ # Resource Limits
887
+ # ─────────────────────────────────────────────────────────────────────────────
888
+ # Memory: 256m, 512m, 1g, 2g, etc.
889
+ # CPU: Number of cores (can be fractional, e.g., 0.5)
890
+ resources:
891
+ memory: 512m
892
+ cpu: 1
893
+
894
+ # ─────────────────────────────────────────────────────────────────────────────
895
+ # Health Check
896
+ # ─────────────────────────────────────────────────────────────────────────────
897
+ # Path that returns 200 OK when app is healthy
898
+ healthcheck:
899
+ path: /health
900
+ timeout: 30
901
+ attempts: 5
902
+
903
+ # ─────────────────────────────────────────────────────────────────────────────
904
+ # Scaling
905
+ # ─────────────────────────────────────────────────────────────────────────────
906
+ # Number of web workers (dynos)
907
+ scale:
908
+ web: 1
909
+ # worker: 1 # Uncomment for background workers
910
+
911
+ # ─────────────────────────────────────────────────────────────────────────────
912
+ # Custom Domains
913
+ # ─────────────────────────────────────────────────────────────────────────────
914
+ # Additional domains for this app (requires DNS configuration)
915
+ # custom_domains:
916
+ # - www.example.com
917
+ # - api.example.com
918
+
919
+ # ─────────────────────────────────────────────────────────────────────────────
920
+ # Environment-Specific Overrides
921
+ # ─────────────────────────────────────────────────────────────────────────────
922
+ environments:
923
+ production:
924
+ resources:
925
+ memory: 1g
926
+ cpu: 2
927
+ scale:
928
+ web: 2
929
+
930
+ staging:
931
+ resources:
932
+ memory: 512m
933
+ cpu: 1
934
+
935
+ preview:
936
+ resources:
937
+ memory: 256m
938
+ cpu: 0.5
939
+ '''
940
+
941
+ SERVICES_TEMPLATE = '''# ═══════════════════════════════════════════════════════════════════════════════
942
+ # Dokku Services Configuration
943
+ # ═══════════════════════════════════════════════════════════════════════════════
944
+ #
945
+ # Define which backing services your app needs.
946
+ # Services are automatically provisioned and linked during deployment.
947
+ #
948
+ # When a service is linked, its connection URL is automatically
949
+ # injected as an environment variable (e.g., DATABASE_URL, REDIS_URL).
950
+ #
951
+ # ═══════════════════════════════════════════════════════════════════════════════
952
+
953
+ services:
954
+ # ─────────────────────────────────────────────────────────────────────────────
955
+ # PostgreSQL Database
956
+ # ─────────────────────────────────────────────────────────────────────────────
957
+ # Environment variable: DATABASE_URL
958
+ # Format: postgres://user:pass@host:5432/database
959
+ postgres:
960
+ enabled: false # Set to true to enable
961
+ environments:
962
+ production:
963
+ # Production gets its own dedicated database
964
+ dedicated: true
965
+ staging:
966
+ # Staging gets its own database
967
+ dedicated: true
968
+ preview:
969
+ # All preview apps share a single database to save resources
970
+ shared: true
971
+
972
+ # ─────────────────────────────────────────────────────────────────────────────
973
+ # Redis Cache/Queue
974
+ # ─────────────────────────────────────────────────────────────────────────────
975
+ # Environment variable: REDIS_URL
976
+ # Format: redis://host:6379
977
+ redis:
978
+ enabled: false # Set to true to enable
979
+ environments:
980
+ production:
981
+ dedicated: true
982
+ staging:
983
+ dedicated: true
984
+ preview:
985
+ shared: true
986
+
987
+ # ─────────────────────────────────────────────────────────────────────────────
988
+ # MySQL Database
989
+ # ─────────────────────────────────────────────────────────────────────────────
990
+ # Environment variable: DATABASE_URL
991
+ # Format: mysql://user:pass@host:3306/database
992
+ mysql:
993
+ enabled: false
994
+ environments:
995
+ preview:
996
+ shared: true
997
+
998
+ # ─────────────────────────────────────────────────────────────────────────────
999
+ # MongoDB
1000
+ # ─────────────────────────────────────────────────────────────────────────────
1001
+ # Environment variable: MONGO_URL
1002
+ # Format: mongodb://user:pass@host:27017/database
1003
+ mongo:
1004
+ enabled: false
1005
+ environments:
1006
+ preview:
1007
+ shared: true
1008
+
1009
+ # ─────────────────────────────────────────────────────────────────────────────
1010
+ # RabbitMQ Message Queue
1011
+ # ─────────────────────────────────────────────────────────────────────────────
1012
+ # Environment variable: RABBITMQ_URL
1013
+ # Format: amqp://user:pass@host:5672
1014
+ rabbitmq:
1015
+ enabled: false
1016
+ environments:
1017
+ preview:
1018
+ # Don't provision RabbitMQ for previews (too expensive)
1019
+ enabled: false
1020
+
1021
+ # ─────────────────────────────────────────────────────────────────────────────
1022
+ # Elasticsearch
1023
+ # ─────────────────────────────────────────────────────────────────────────────
1024
+ # Environment variable: ELASTICSEARCH_URL
1025
+ # Format: http://host:9200
1026
+ elasticsearch:
1027
+ enabled: false
1028
+ environments:
1029
+ preview:
1030
+ enabled: false
1031
+
1032
+ # ═══════════════════════════════════════════════════════════════════════════════
1033
+ # External/Managed Services
1034
+ # ═══════════════════════════════════════════════════════════════════════════════
1035
+ #
1036
+ # For production, you may want to use managed services like AWS RDS,
1037
+ # ElastiCache, etc. Define the connection URLs here (reference GitHub secrets).
1038
+ #
1039
+ # These override the Dokku-managed services for the specified environment.
1040
+ #
1041
+ # ═══════════════════════════════════════════════════════════════════════════════
1042
+
1043
+ # external_services:
1044
+ # production:
1045
+ # DATABASE_URL: "${secrets.PROD_DATABASE_URL}"
1046
+ # REDIS_URL: "${secrets.PROD_REDIS_URL}"
1047
+ # staging:
1048
+ # DATABASE_URL: "${secrets.STAGING_DATABASE_URL}"
1049
+ '''
1050
+
1051
+ README_CICD_TEMPLATE = '''# {app_name}
1052
+
1053
+ A SignalWire AI Agent with automated GitHub → Dokku deployments.
1054
+
1055
+ ## Features
1056
+
1057
+ - ✅ Auto-deploy on push to main/staging/develop
1058
+ - ✅ Preview environments for pull requests
1059
+ - ✅ Automatic SSL via Let's Encrypt
1060
+ - ✅ Zero-downtime deployments
1061
+ - ✅ Multi-environment support
1062
+
1063
+ ## Setup
1064
+
1065
+ ### 1. GitHub Secrets
1066
+
1067
+ Add these secrets to your repository (Settings → Secrets → Actions):
1068
+
1069
+ | Secret | Description |
1070
+ |--------|-------------|
1071
+ | `DOKKU_HOST` | Your Dokku server hostname |
1072
+ | `DOKKU_SSH_PRIVATE_KEY` | SSH private key for deployments |
1073
+ | `BASE_DOMAIN` | Base domain (e.g., `yourdomain.com`) |
1074
+ | `SWML_BASIC_AUTH_USER` | Basic auth username |
1075
+ | `SWML_BASIC_AUTH_PASSWORD` | Basic auth password |
1076
+
1077
+ ### 2. GitHub Environments
1078
+
1079
+ Create these environments (Settings → Environments):
1080
+ - `production` - Deploy from `main` branch
1081
+ - `staging` - Deploy from `staging` branch
1082
+ - `development` - Deploy from `develop` branch
1083
+ - `preview` - Deploy from pull requests
1084
+
1085
+ ### 3. Deploy
1086
+
1087
+ Just push to a branch:
1088
+
1089
+ ```bash
1090
+ git push origin main # → {app_name}.yourdomain.com
1091
+ git push origin staging # → {app_name}-staging.yourdomain.com
1092
+ git push origin develop # → {app_name}-dev.yourdomain.com
1093
+ ```
1094
+
1095
+ Or open a PR for a preview environment.
1096
+
1097
+ ## Branch → Environment Mapping
1098
+
1099
+ | Branch | App Name | URL |
1100
+ |--------|----------|-----|
1101
+ | `main` | `{app_name}` | `{app_name}.yourdomain.com` |
1102
+ | `staging` | `{app_name}-staging` | `{app_name}-staging.yourdomain.com` |
1103
+ | `develop` | `{app_name}-dev` | `{app_name}-dev.yourdomain.com` |
1104
+ | PR #42 | `{app_name}-pr-42` | `{app_name}-pr-42.yourdomain.com` |
1105
+
1106
+ ## Manual Operations
1107
+
1108
+ ```bash
1109
+ # View logs
1110
+ ssh dokku@server logs {app_name} -t
1111
+
1112
+ # SSH into container
1113
+ ssh dokku@server enter {app_name}
1114
+
1115
+ # Restart
1116
+ ssh dokku@server ps:restart {app_name}
1117
+
1118
+ # Rollback
1119
+ ssh dokku@server releases:rollback {app_name}
1120
+
1121
+ # Scale
1122
+ ssh dokku@server ps:scale {app_name} web=2
1123
+ ```
1124
+
1125
+ ## Local Development
1126
+
1127
+ ```bash
1128
+ pip install -r requirements.txt
1129
+ uvicorn app:app --reload --port 8080
1130
+ ```
1131
+
1132
+ Test with swaig-test:
1133
+ ```bash
1134
+ swaig-test app.py --list-tools
1135
+ ```
1136
+ '''
1137
+
1138
+
1139
+ # =============================================================================
1140
+ # Project Generator
1141
+ # =============================================================================
1142
+
1143
+ class DokkuProjectGenerator:
1144
+ """Generates Dokku deployment files for SignalWire agents."""
1145
+
1146
+ def __init__(self, app_name: str, options: Dict[str, Any]):
1147
+ self.app_name = app_name
1148
+ self.options = options
1149
+ self.project_dir = Path(options.get('project_dir', f'./{app_name}'))
1150
+
1151
+ # Derived names
1152
+ self.agent_slug = app_name.lower().replace(' ', '-').replace('_', '-')
1153
+ self.agent_class = ''.join(
1154
+ word.capitalize()
1155
+ for word in app_name.replace('-', ' ').replace('_', ' ').split()
1156
+ ) + 'Agent'
1157
+
1158
+ def generate(self) -> bool:
1159
+ """Generate the project files."""
1160
+ try:
1161
+ self.project_dir.mkdir(parents=True, exist_ok=True)
1162
+ print_success(f"Created {self.project_dir}/")
1163
+
1164
+ # Core files (both modes)
1165
+ self._write_core_files()
1166
+
1167
+ # Mode-specific files
1168
+ if self.options.get('cicd'):
1169
+ self._write_cicd_files()
1170
+ else:
1171
+ self._write_simple_files()
1172
+
1173
+ return True
1174
+ except Exception as e:
1175
+ print_error(f"Failed to generate project: {e}")
1176
+ return False
1177
+
1178
+ def _write_core_files(self):
1179
+ """Write files common to both modes."""
1180
+ # Procfile
1181
+ self._write_file('Procfile', PROCFILE_TEMPLATE)
1182
+
1183
+ # runtime.txt
1184
+ self._write_file('runtime.txt', RUNTIME_TEMPLATE)
1185
+
1186
+ # requirements.txt
1187
+ self._write_file('requirements.txt', REQUIREMENTS_TEMPLATE)
1188
+
1189
+ # CHECKS
1190
+ self._write_file('CHECKS', CHECKS_TEMPLATE)
1191
+
1192
+ # .gitignore
1193
+ self._write_file('.gitignore', GITIGNORE_TEMPLATE)
1194
+
1195
+ # .env.example
1196
+ self._write_file('.env.example', ENV_EXAMPLE_TEMPLATE.format(
1197
+ app_name=self.app_name
1198
+ ))
1199
+
1200
+ # app.json
1201
+ self._write_file('app.json', APP_JSON_TEMPLATE.format(
1202
+ app_name=self.app_name
1203
+ ))
1204
+
1205
+ # app.py - use web template if web option is enabled
1206
+ if self.options.get('web'):
1207
+ self._write_file('app.py', APP_TEMPLATE_WITH_WEB.format(
1208
+ agent_name=self.app_name,
1209
+ agent_class=self.agent_class,
1210
+ agent_slug=self.agent_slug
1211
+ ))
1212
+ self._write_web_files()
1213
+ else:
1214
+ self._write_file('app.py', APP_TEMPLATE.format(
1215
+ agent_name=self.app_name,
1216
+ agent_class=self.agent_class,
1217
+ agent_slug=self.agent_slug
1218
+ ))
1219
+
1220
+ def _write_web_files(self):
1221
+ """Write web interface files."""
1222
+ # Create web directory
1223
+ web_dir = self.project_dir / 'web'
1224
+ web_dir.mkdir(parents=True, exist_ok=True)
1225
+
1226
+ route = self.options.get('route', 'swaig')
1227
+
1228
+ # index.html
1229
+ self._write_file('web/index.html', WEB_INDEX_TEMPLATE.format(
1230
+ agent_name=self.app_name,
1231
+ route=route
1232
+ ))
1233
+
1234
+ def _write_simple_files(self):
1235
+ """Write files for simple deployment mode."""
1236
+ dokku_host = self.options.get('dokku_host', 'dokku.yourdomain.com')
1237
+ route = self.options.get('route', 'swaig')
1238
+
1239
+ # deploy.sh
1240
+ deploy_script = DEPLOY_SCRIPT_TEMPLATE.format(
1241
+ app_name=self.app_name,
1242
+ dokku_host=dokku_host,
1243
+ route=route
1244
+ )
1245
+ self._write_file('deploy.sh', deploy_script, executable=True)
1246
+
1247
+ # README.md
1248
+ readme = README_SIMPLE_TEMPLATE.format(
1249
+ app_name=self.app_name,
1250
+ dokku_host=dokku_host,
1251
+ dokku_host_domain=dokku_host.replace('dokku.', ''),
1252
+ route=route
1253
+ )
1254
+ self._write_file('README.md', readme)
1255
+
1256
+ def _write_cicd_files(self):
1257
+ """Write files for CI/CD deployment mode."""
1258
+ # Create .github/workflows directory
1259
+ workflows_dir = self.project_dir / '.github' / 'workflows'
1260
+ workflows_dir.mkdir(parents=True, exist_ok=True)
1261
+
1262
+ # deploy.yml
1263
+ deploy_workflow = DEPLOY_WORKFLOW_TEMPLATE.format(app_name=self.app_name)
1264
+ self._write_file('.github/workflows/deploy.yml', deploy_workflow)
1265
+
1266
+ # preview.yml
1267
+ preview_workflow = PREVIEW_WORKFLOW_TEMPLATE.format(app_name=self.app_name)
1268
+ self._write_file('.github/workflows/preview.yml', preview_workflow)
1269
+
1270
+ # Create .dokku directory
1271
+ dokku_dir = self.project_dir / '.dokku'
1272
+ dokku_dir.mkdir(parents=True, exist_ok=True)
1273
+
1274
+ # config.yml
1275
+ self._write_file('.dokku/config.yml', DOKKU_CONFIG_TEMPLATE)
1276
+
1277
+ # services.yml
1278
+ self._write_file('.dokku/services.yml', SERVICES_TEMPLATE)
1279
+
1280
+ # README.md
1281
+ readme = README_CICD_TEMPLATE.format(app_name=self.app_name)
1282
+ self._write_file('README.md', readme)
1283
+
1284
+ def _write_file(self, path: str, content: str, executable: bool = False):
1285
+ """Write a file to the project directory."""
1286
+ file_path = self.project_dir / path
1287
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1288
+ file_path.write_text(content)
1289
+
1290
+ if executable:
1291
+ file_path.chmod(0o755)
1292
+
1293
+ print_success(f"Created {path}")
1294
+
1295
+
1296
+ # =============================================================================
1297
+ # CLI Commands
1298
+ # =============================================================================
1299
+
1300
+ def cmd_init(args):
1301
+ """Initialize a new Dokku project."""
1302
+ app_name = args.name
1303
+
1304
+ print_header(f"Creating Dokku project: {app_name}")
1305
+
1306
+ # Gather options
1307
+ options = {
1308
+ 'project_dir': args.dir or f'./{app_name}',
1309
+ 'cicd': args.cicd,
1310
+ 'dokku_host': args.host or 'dokku.yourdomain.com',
1311
+ 'route': 'swaig',
1312
+ 'web': args.web,
1313
+ }
1314
+
1315
+ # Interactive mode if not all options provided
1316
+ if not args.host and not args.cicd:
1317
+ print("\n")
1318
+ if prompt_yes_no("Enable GitHub Actions CI/CD?", default=False):
1319
+ options['cicd'] = True
1320
+ else:
1321
+ options['dokku_host'] = prompt("Dokku server hostname", "dokku.yourdomain.com")
1322
+
1323
+ # Ask about web interface if not specified
1324
+ if not args.web:
1325
+ options['web'] = prompt_yes_no("Include web interface (static files at /)?", default=True)
1326
+
1327
+ # Check if directory exists
1328
+ project_dir = Path(options['project_dir'])
1329
+ if project_dir.exists():
1330
+ if not args.force:
1331
+ if not prompt_yes_no(f"Directory {project_dir} exists. Overwrite?", default=False):
1332
+ print("Aborted.")
1333
+ return 1
1334
+ shutil.rmtree(project_dir)
1335
+
1336
+ # Generate project
1337
+ generator = DokkuProjectGenerator(app_name, options)
1338
+ if generator.generate():
1339
+ print(f"\n{Colors.GREEN}{Colors.BOLD}Project created successfully!{Colors.NC}\n")
1340
+
1341
+ if options['cicd']:
1342
+ _print_cicd_instructions(app_name)
1343
+ else:
1344
+ _print_simple_instructions(app_name, options['dokku_host'], project_dir)
1345
+
1346
+ return 0
1347
+ return 1
1348
+
1349
+
1350
+ def _print_simple_instructions(app_name: str, dokku_host: str, project_dir: Path):
1351
+ """Print instructions for simple mode."""
1352
+ print(f"""To deploy your agent:
1353
+
1354
+ {Colors.CYAN}cd {project_dir}{Colors.NC}
1355
+ {Colors.CYAN}./deploy.sh{Colors.NC}
1356
+
1357
+ Or manually:
1358
+
1359
+ {Colors.DIM}git init && git add . && git commit -m "Initial commit"
1360
+ git remote add dokku dokku@{dokku_host}:{app_name}
1361
+ git push dokku main{Colors.NC}
1362
+ """)
1363
+
1364
+
1365
+ def _print_cicd_instructions(app_name: str):
1366
+ """Print instructions for CI/CD mode."""
1367
+ print(f"""
1368
+ {Colors.BOLD}═══════════════════════════════════════════════════════════{Colors.NC}
1369
+ {Colors.BOLD} CI/CD Setup Instructions{Colors.NC}
1370
+ {Colors.BOLD}═══════════════════════════════════════════════════════════{Colors.NC}
1371
+
1372
+ 1. Push this repository to GitHub
1373
+
1374
+ 2. Add these secrets to your GitHub repository:
1375
+ (Settings → Secrets → Actions)
1376
+
1377
+ • {Colors.CYAN}DOKKU_HOST{Colors.NC} - Your Dokku server hostname
1378
+ • {Colors.CYAN}DOKKU_SSH_PRIVATE_KEY{Colors.NC} - SSH key for deployments
1379
+ • {Colors.CYAN}BASE_DOMAIN{Colors.NC} - Base domain (e.g., yourdomain.com)
1380
+ • {Colors.CYAN}SWML_BASIC_AUTH_USER{Colors.NC} - Basic auth username
1381
+ • {Colors.CYAN}SWML_BASIC_AUTH_PASSWORD{Colors.NC} - Basic auth password
1382
+
1383
+ 3. Create GitHub environments:
1384
+ (Settings → Environments)
1385
+
1386
+ • production
1387
+ • staging
1388
+ • development
1389
+ • preview
1390
+
1391
+ 4. Push to deploy:
1392
+
1393
+ {Colors.CYAN}git push origin main{Colors.NC} # Deploys to production
1394
+
1395
+ 5. Open a PR for preview environments!
1396
+
1397
+ {Colors.BOLD}═══════════════════════════════════════════════════════════{Colors.NC}
1398
+ """)
1399
+
1400
+
1401
+ def cmd_deploy(args):
1402
+ """Deploy to Dokku."""
1403
+ # Check if we're in a Dokku project
1404
+ if not Path('Procfile').exists():
1405
+ print_error("No Procfile found. Are you in a Dokku project directory?")
1406
+ print("Run 'sw-agent-dokku init <name>' to create a new project.")
1407
+ return 1
1408
+
1409
+ dokku_host = args.host
1410
+ app_name = args.app
1411
+
1412
+ # Try to get app name from app.json
1413
+ if not app_name and Path('app.json').exists():
1414
+ import json
1415
+ try:
1416
+ with open('app.json') as f:
1417
+ app_json = json.load(f)
1418
+ app_name = app_json.get('name')
1419
+ except:
1420
+ pass
1421
+
1422
+ if not app_name:
1423
+ app_name = prompt("App name", Path.cwd().name)
1424
+
1425
+ if not dokku_host:
1426
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
1427
+
1428
+ print_header(f"Deploying {app_name} to {dokku_host}")
1429
+
1430
+ # Check git status
1431
+ if not Path('.git').exists():
1432
+ print_step("Initializing git repository...")
1433
+ subprocess.run(['git', 'init'], check=True)
1434
+ subprocess.run(['git', 'add', '.'], check=True)
1435
+ subprocess.run(['git', 'commit', '-m', 'Initial commit'], check=True)
1436
+
1437
+ # Create app
1438
+ print_step("Creating app (if not exists)...")
1439
+ subprocess.run(
1440
+ ['ssh', f'dokku@{dokku_host}', 'apps:create', app_name],
1441
+ capture_output=True
1442
+ )
1443
+
1444
+ # Set up git remote
1445
+ print_step("Configuring git remote...")
1446
+ remote_url = f'dokku@{dokku_host}:{app_name}'
1447
+ subprocess.run(['git', 'remote', 'remove', 'dokku'], capture_output=True)
1448
+ subprocess.run(['git', 'remote', 'add', 'dokku', remote_url], check=True)
1449
+
1450
+ # Deploy
1451
+ print_step("Pushing to Dokku...")
1452
+ result = subprocess.run(
1453
+ ['git', 'push', 'dokku', 'HEAD:main', '--force'],
1454
+ capture_output=False
1455
+ )
1456
+
1457
+ if result.returncode == 0:
1458
+ print_success(f"Deployed to https://{app_name}.{dokku_host.replace('dokku.', '')}")
1459
+ else:
1460
+ print_error("Deployment failed")
1461
+ return 1
1462
+
1463
+ return 0
1464
+
1465
+
1466
+ def cmd_logs(args):
1467
+ """Tail Dokku logs."""
1468
+ app_name = args.app
1469
+ dokku_host = args.host
1470
+
1471
+ if not app_name:
1472
+ app_name = _get_app_name()
1473
+ if not dokku_host:
1474
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
1475
+
1476
+ print_header(f"Tailing logs for {app_name}")
1477
+
1478
+ cmd = ['ssh', f'dokku@{dokku_host}', 'logs', app_name]
1479
+ if args.tail:
1480
+ cmd.append('-t')
1481
+ if args.num:
1482
+ cmd.extend(['--num', str(args.num)])
1483
+
1484
+ subprocess.run(cmd)
1485
+ return 0
1486
+
1487
+
1488
+ def cmd_config(args):
1489
+ """Manage Dokku config."""
1490
+ app_name = args.app
1491
+ dokku_host = args.host
1492
+
1493
+ if not app_name:
1494
+ app_name = _get_app_name()
1495
+ if not dokku_host:
1496
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
1497
+
1498
+ if args.config_action == 'show':
1499
+ subprocess.run(['ssh', f'dokku@{dokku_host}', 'config:show', app_name])
1500
+ elif args.config_action == 'set':
1501
+ if not args.vars:
1502
+ print_error("No variables provided. Use: sw-agent-dokku config set KEY=value")
1503
+ return 1
1504
+ cmd = ['ssh', f'dokku@{dokku_host}', 'config:set', app_name] + args.vars
1505
+ subprocess.run(cmd)
1506
+ elif args.config_action == 'unset':
1507
+ if not args.vars:
1508
+ print_error("No variables provided. Use: sw-agent-dokku config unset KEY")
1509
+ return 1
1510
+ cmd = ['ssh', f'dokku@{dokku_host}', 'config:unset', app_name] + args.vars
1511
+ subprocess.run(cmd)
1512
+
1513
+ return 0
1514
+
1515
+
1516
+ def cmd_scale(args):
1517
+ """Scale Dokku processes."""
1518
+ app_name = args.app
1519
+ dokku_host = args.host
1520
+
1521
+ if not app_name:
1522
+ app_name = _get_app_name()
1523
+ if not dokku_host:
1524
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
1525
+
1526
+ if not args.scale_args:
1527
+ # Show current scale
1528
+ subprocess.run(['ssh', f'dokku@{dokku_host}', 'ps:scale', app_name])
1529
+ else:
1530
+ # Set scale
1531
+ cmd = ['ssh', f'dokku@{dokku_host}', 'ps:scale', app_name] + args.scale_args
1532
+ subprocess.run(cmd)
1533
+
1534
+ return 0
1535
+
1536
+
1537
+ def _get_app_name() -> str:
1538
+ """Try to get app name from app.json or prompt."""
1539
+ if Path('app.json').exists():
1540
+ import json
1541
+ try:
1542
+ with open('app.json') as f:
1543
+ return json.load(f).get('name', '')
1544
+ except:
1545
+ pass
1546
+ return prompt("App name", Path.cwd().name)
1547
+
1548
+
1549
+ # =============================================================================
1550
+ # Main Entry Point
1551
+ # =============================================================================
1552
+
1553
+ def main():
1554
+ parser = argparse.ArgumentParser(
1555
+ description='SignalWire Agent Dokku Deployment Tool',
1556
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1557
+ epilog='''
1558
+ Examples:
1559
+ sw-agent-dokku init myagent # Create simple project (with prompts)
1560
+ sw-agent-dokku init myagent --web # Create with web interface at /
1561
+ sw-agent-dokku init myagent --cicd # Create with CI/CD workflows
1562
+ sw-agent-dokku init myagent --host dokku.example.com
1563
+ sw-agent-dokku deploy # Deploy current directory
1564
+ sw-agent-dokku logs -t # Tail logs
1565
+ sw-agent-dokku config show # Show config
1566
+ sw-agent-dokku config set KEY=value # Set config
1567
+ sw-agent-dokku scale web=2 # Scale processes
1568
+ '''
1569
+ )
1570
+
1571
+ subparsers = parser.add_subparsers(dest='command', help='Commands')
1572
+
1573
+ # init command
1574
+ init_parser = subparsers.add_parser('init', help='Initialize a new Dokku project')
1575
+ init_parser.add_argument('name', help='Project/app name')
1576
+ init_parser.add_argument('--cicd', action='store_true',
1577
+ help='Include GitHub Actions CI/CD workflows')
1578
+ init_parser.add_argument('--web', action='store_true',
1579
+ help='Include web interface (static files at /)')
1580
+ init_parser.add_argument('--host', help='Dokku server hostname')
1581
+ init_parser.add_argument('--dir', help='Project directory')
1582
+ init_parser.add_argument('--force', '-f', action='store_true',
1583
+ help='Overwrite existing directory')
1584
+
1585
+ # deploy command
1586
+ deploy_parser = subparsers.add_parser('deploy', help='Deploy to Dokku')
1587
+ deploy_parser.add_argument('--app', '-a', help='App name')
1588
+ deploy_parser.add_argument('--host', '-H', help='Dokku server hostname')
1589
+
1590
+ # logs command
1591
+ logs_parser = subparsers.add_parser('logs', help='View Dokku logs')
1592
+ logs_parser.add_argument('--app', '-a', help='App name')
1593
+ logs_parser.add_argument('--host', '-H', help='Dokku server hostname')
1594
+ logs_parser.add_argument('--tail', '-t', action='store_true', help='Tail logs')
1595
+ logs_parser.add_argument('--num', '-n', type=int, help='Number of lines')
1596
+
1597
+ # config command
1598
+ config_parser = subparsers.add_parser('config', help='Manage config variables')
1599
+ config_parser.add_argument('config_action', choices=['show', 'set', 'unset'],
1600
+ help='Config action')
1601
+ config_parser.add_argument('vars', nargs='*', help='Variables (KEY=value)')
1602
+ config_parser.add_argument('--app', '-a', help='App name')
1603
+ config_parser.add_argument('--host', '-H', help='Dokku server hostname')
1604
+
1605
+ # scale command
1606
+ scale_parser = subparsers.add_parser('scale', help='Scale processes')
1607
+ scale_parser.add_argument('scale_args', nargs='*', help='Scale args (web=2)')
1608
+ scale_parser.add_argument('--app', '-a', help='App name')
1609
+ scale_parser.add_argument('--host', '-H', help='Dokku server hostname')
1610
+
1611
+ args = parser.parse_args()
1612
+
1613
+ if not args.command:
1614
+ parser.print_help()
1615
+ return 1
1616
+
1617
+ commands = {
1618
+ 'init': cmd_init,
1619
+ 'deploy': cmd_deploy,
1620
+ 'logs': cmd_logs,
1621
+ 'config': cmd_config,
1622
+ 'scale': cmd_scale,
1623
+ }
1624
+
1625
+ return commands[args.command](args)
1626
+
1627
+
1628
+ if __name__ == '__main__':
1629
+ sys.exit(main())