signalwire-agents 1.0.12__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.
- signalwire_agents/__init__.py +1 -1
- signalwire_agents/agent_server.py +25 -9
- signalwire_agents/cli/dokku.py +1629 -0
- {signalwire_agents-1.0.12.dist-info → signalwire_agents-1.0.13.dist-info}/METADATA +1 -1
- {signalwire_agents-1.0.12.dist-info → signalwire_agents-1.0.13.dist-info}/RECORD +12 -11
- {signalwire_agents-1.0.12.dist-info → signalwire_agents-1.0.13.dist-info}/entry_points.txt +1 -0
- {signalwire_agents-1.0.12.data → signalwire_agents-1.0.13.data}/data/share/man/man1/sw-agent-init.1 +0 -0
- {signalwire_agents-1.0.12.data → signalwire_agents-1.0.13.data}/data/share/man/man1/sw-search.1 +0 -0
- {signalwire_agents-1.0.12.data → signalwire_agents-1.0.13.data}/data/share/man/man1/swaig-test.1 +0 -0
- {signalwire_agents-1.0.12.dist-info → signalwire_agents-1.0.13.dist-info}/WHEEL +0 -0
- {signalwire_agents-1.0.12.dist-info → signalwire_agents-1.0.13.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-1.0.12.dist-info → signalwire_agents-1.0.13.dist-info}/top_level.txt +0 -0
|
@@ -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())
|