summaryrefslogtreecommitdiffstats
path: root/cmd/deploy
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/deploy')
-rw-r--r--cmd/deploy/deploy.go494
-rw-r--r--cmd/deploy/env.go176
-rw-r--r--cmd/deploy/init.go154
-rw-r--r--cmd/deploy/list.go58
-rw-r--r--cmd/deploy/main.go82
-rw-r--r--cmd/deploy/manage.go327
-rw-r--r--cmd/deploy/templates/webui.html440
-rw-r--r--cmd/deploy/webui.go228
8 files changed, 1959 insertions, 0 deletions
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go
new file mode 100644
index 0000000..2b3ab4a
--- /dev/null
+++ b/cmd/deploy/deploy.go
@@ -0,0 +1,494 @@
1package main
2
3import (
4 "bufio"
5 "flag"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11
12 "github.com/bdw/deploy/internal/config"
13 "github.com/bdw/deploy/internal/ssh"
14 "github.com/bdw/deploy/internal/state"
15 "github.com/bdw/deploy/internal/templates"
16)
17
18type envFlags []string
19
20func (e *envFlags) String() string {
21 return strings.Join(*e, ",")
22}
23
24func (e *envFlags) Set(value string) error {
25 *e = append(*e, value)
26 return nil
27}
28
29type fileFlags []string
30
31func (f *fileFlags) String() string {
32 return strings.Join(*f, ",")
33}
34
35func (f *fileFlags) Set(value string) error {
36 *f = append(*f, value)
37 return nil
38}
39
40func runDeploy(args []string) {
41 fs := flag.NewFlagSet("deploy", flag.ExitOnError)
42 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
43 domain := fs.String("domain", "", "Domain name (required)")
44 name := fs.String("name", "", "App name (default: inferred from binary or directory)")
45 binary := fs.String("binary", "", "Path to Go binary (for app deployment)")
46 static := fs.Bool("static", false, "Deploy as static site")
47 dir := fs.String("dir", ".", "Directory to deploy (for static sites)")
48 port := fs.Int("port", 0, "Port override (default: auto-allocate)")
49 var envVars envFlags
50 fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)")
51 envFile := fs.String("env-file", "", "Path to .env file")
52 binaryArgs := fs.String("args", "", "Arguments to pass to binary")
53 var files fileFlags
54 fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)")
55
56 fs.Parse(args)
57
58 // Get host from flag or config
59 if *host == "" {
60 cfg, err := config.Load()
61 if err != nil {
62 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
63 os.Exit(1)
64 }
65 *host = cfg.Host
66 }
67
68 if *host == "" || *domain == "" {
69 fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n")
70 fs.Usage()
71 os.Exit(1)
72 }
73
74 if *static {
75 deployStatic(*host, *domain, *name, *dir)
76 } else {
77 deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files)
78 }
79}
80
81func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) {
82 // Determine app name
83 if name == "" {
84 if binaryPath != "" {
85 name = filepath.Base(binaryPath)
86 } else {
87 // Try to find a binary in current directory
88 cwd, _ := os.Getwd()
89 name = filepath.Base(cwd)
90 }
91 }
92
93 // Find binary if not specified
94 if binaryPath == "" {
95 // Look for binary with same name as directory
96 if _, err := os.Stat(name); err == nil {
97 binaryPath = name
98 } else {
99 fmt.Fprintf(os.Stderr, "Error: --binary is required (could not find binary in current directory)\n")
100 os.Exit(1)
101 }
102 }
103
104 // Verify binary exists
105 if _, err := os.Stat(binaryPath); err != nil {
106 fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath)
107 os.Exit(1)
108 }
109
110 fmt.Printf("Deploying app: %s\n", name)
111 fmt.Printf(" Domain: %s\n", domain)
112 fmt.Printf(" Binary: %s\n", binaryPath)
113
114 // Load state
115 st, err := state.Load()
116 if err != nil {
117 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
118 os.Exit(1)
119 }
120
121 // Check if app already exists (update) or new deployment
122 existingApp, _ := st.GetApp(host, name)
123 var port int
124 if existingApp != nil {
125 port = existingApp.Port
126 fmt.Printf(" Updating existing deployment (port %d)\n", port)
127 } else {
128 if portOverride > 0 {
129 port = portOverride
130 } else {
131 port = st.AllocatePort(host)
132 }
133 fmt.Printf(" Allocated port: %d\n", port)
134 }
135
136 // Parse environment variables
137 env := make(map[string]string)
138 if existingApp != nil {
139 // Preserve existing env vars
140 for k, v := range existingApp.Env {
141 env[k] = v
142 }
143 // Preserve existing args if not provided
144 if args == "" && existingApp.Args != "" {
145 args = existingApp.Args
146 }
147 // Preserve existing files if not provided
148 if len(files) == 0 && len(existingApp.Files) > 0 {
149 files = existingApp.Files
150 }
151 }
152
153 // Add/override from flags
154 for _, e := range envVars {
155 parts := strings.SplitN(e, "=", 2)
156 if len(parts) == 2 {
157 env[parts[0]] = parts[1]
158 }
159 }
160
161 // Add/override from file
162 if envFile != "" {
163 fileEnv, err := parseEnvFile(envFile)
164 if err != nil {
165 fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err)
166 os.Exit(1)
167 }
168 for k, v := range fileEnv {
169 env[k] = v
170 }
171 }
172
173 // Always set PORT
174 env["PORT"] = strconv.Itoa(port)
175
176 // Connect to VPS
177 client, err := ssh.Connect(host)
178 if err != nil {
179 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
180 os.Exit(1)
181 }
182 defer client.Close()
183
184 // Upload binary
185 fmt.Println("→ Uploading binary...")
186 remoteTmpPath := fmt.Sprintf("/tmp/%s", name)
187 if err := client.Upload(binaryPath, remoteTmpPath); err != nil {
188 fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err)
189 os.Exit(1)
190 }
191
192 // Create user (ignore error if already exists)
193 fmt.Println("→ Creating system user...")
194 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name))
195
196 // Create working directory
197 fmt.Println("→ Setting up directories...")
198 workDir := fmt.Sprintf("/var/lib/%s", name)
199 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
200 fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err)
201 os.Exit(1)
202 }
203 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil {
204 fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err)
205 os.Exit(1)
206 }
207
208 // Move binary to /usr/local/bin
209 fmt.Println("→ Installing binary...")
210 binaryDest := fmt.Sprintf("/usr/local/bin/%s", name)
211 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
212 fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err)
213 os.Exit(1)
214 }
215 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
216 fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err)
217 os.Exit(1)
218 }
219
220 // Upload config files to working directory
221 if len(files) > 0 {
222 fmt.Println("→ Uploading config files...")
223 for _, file := range files {
224 // Verify file exists locally
225 if _, err := os.Stat(file); err != nil {
226 fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file)
227 os.Exit(1)
228 }
229
230 // Determine remote path (preserve filename)
231 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
232 remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file))
233
234 // Upload to tmp first
235 if err := client.Upload(file, remoteTmpPath); err != nil {
236 fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err)
237 os.Exit(1)
238 }
239
240 // Move to working directory
241 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil {
242 fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err)
243 os.Exit(1)
244 }
245
246 // Set ownership
247 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil {
248 fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err)
249 os.Exit(1)
250 }
251
252 fmt.Printf(" Uploaded: %s\n", file)
253 }
254 }
255
256 // Create env file
257 fmt.Println("→ Creating environment file...")
258 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
259 envContent := ""
260 for k, v := range env {
261 envContent += fmt.Sprintf("%s=%s\n", k, v)
262 }
263 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
264 fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err)
265 os.Exit(1)
266 }
267 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
268 fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err)
269 os.Exit(1)
270 }
271 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil {
272 fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err)
273 os.Exit(1)
274 }
275
276 // Generate systemd unit
277 fmt.Println("→ Creating systemd service...")
278 serviceContent, err := templates.SystemdService(map[string]string{
279 "Name": name,
280 "User": name,
281 "WorkDir": workDir,
282 "BinaryPath": binaryDest,
283 "Port": strconv.Itoa(port),
284 "EnvFile": envFilePath,
285 "Args": args,
286 })
287 if err != nil {
288 fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err)
289 os.Exit(1)
290 }
291
292 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
293 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
294 fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err)
295 os.Exit(1)
296 }
297
298 // Generate Caddy config
299 fmt.Println("→ Configuring Caddy...")
300 caddyContent, err := templates.AppCaddy(map[string]string{
301 "Domain": domain,
302 "Port": strconv.Itoa(port),
303 })
304 if err != nil {
305 fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err)
306 os.Exit(1)
307 }
308
309 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
310 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
311 fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err)
312 os.Exit(1)
313 }
314
315 // Reload systemd
316 fmt.Println("→ Reloading systemd...")
317 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
318 fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err)
319 os.Exit(1)
320 }
321
322 // Enable and start service
323 fmt.Println("→ Starting service...")
324 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil {
325 fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err)
326 os.Exit(1)
327 }
328 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
329 fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err)
330 os.Exit(1)
331 }
332
333 // Reload Caddy
334 fmt.Println("→ Reloading Caddy...")
335 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
336 fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err)
337 os.Exit(1)
338 }
339
340 // Update state
341 st.AddApp(host, name, &state.App{
342 Type: "app",
343 Domain: domain,
344 Port: port,
345 Env: env,
346 Args: args,
347 Files: files,
348 })
349 if err := st.Save(); err != nil {
350 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
351 os.Exit(1)
352 }
353
354 fmt.Printf("\n✓ App deployed successfully!\n")
355 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
356}
357
358func deployStatic(host, domain, name, dir string) {
359 // Determine site name (default to domain to avoid conflicts)
360 if name == "" {
361 name = domain
362 }
363
364 // Verify directory exists
365 if _, err := os.Stat(dir); err != nil {
366 fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir)
367 os.Exit(1)
368 }
369
370 fmt.Printf("Deploying static site: %s\n", name)
371 fmt.Printf(" Domain: %s\n", domain)
372 fmt.Printf(" Directory: %s\n", dir)
373
374 // Load state
375 st, err := state.Load()
376 if err != nil {
377 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
378 os.Exit(1)
379 }
380
381 // Connect to VPS
382 client, err := ssh.Connect(host)
383 if err != nil {
384 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
385 os.Exit(1)
386 }
387 defer client.Close()
388
389 // Create remote directory
390 remoteDir := fmt.Sprintf("/var/www/%s", name)
391 fmt.Println("→ Creating remote directory...")
392 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
393 fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err)
394 os.Exit(1)
395 }
396
397 // Get current user for temporary ownership during upload
398 currentUser, err := client.Run("whoami")
399 if err != nil {
400 fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err)
401 os.Exit(1)
402 }
403 currentUser = strings.TrimSpace(currentUser)
404
405 // Set ownership to current user for upload
406 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
407 fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err)
408 os.Exit(1)
409 }
410
411 // Upload files
412 fmt.Println("→ Uploading files...")
413 if err := client.UploadDir(dir, remoteDir); err != nil {
414 fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err)
415 os.Exit(1)
416 }
417
418 // Set ownership and permissions
419 fmt.Println("→ Setting permissions...")
420 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
421 fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err)
422 os.Exit(1)
423 }
424 // Make files readable by all (755 for dirs, 644 for files)
425 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
426 fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err)
427 os.Exit(1)
428 }
429 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
430 fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err)
431 os.Exit(1)
432 }
433
434 // Generate Caddy config
435 fmt.Println("→ Configuring Caddy...")
436 caddyContent, err := templates.StaticCaddy(map[string]string{
437 "Domain": domain,
438 "RootDir": remoteDir,
439 })
440 if err != nil {
441 fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err)
442 os.Exit(1)
443 }
444
445 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
446 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
447 fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err)
448 os.Exit(1)
449 }
450
451 // Reload Caddy
452 fmt.Println("→ Reloading Caddy...")
453 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
454 fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err)
455 os.Exit(1)
456 }
457
458 // Update state
459 st.AddApp(host, name, &state.App{
460 Type: "static",
461 Domain: domain,
462 })
463 if err := st.Save(); err != nil {
464 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
465 os.Exit(1)
466 }
467
468 fmt.Printf("\n✓ Static site deployed successfully!\n")
469 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
470}
471
472func parseEnvFile(path string) (map[string]string, error) {
473 file, err := os.Open(path)
474 if err != nil {
475 return nil, err
476 }
477 defer file.Close()
478
479 env := make(map[string]string)
480 scanner := bufio.NewScanner(file)
481 for scanner.Scan() {
482 line := strings.TrimSpace(scanner.Text())
483 if line == "" || strings.HasPrefix(line, "#") {
484 continue
485 }
486
487 parts := strings.SplitN(line, "=", 2)
488 if len(parts) == 2 {
489 env[parts[0]] = parts[1]
490 }
491 }
492
493 return env, scanner.Err()
494}
diff --git a/cmd/deploy/env.go b/cmd/deploy/env.go
new file mode 100644
index 0000000..135fb77
--- /dev/null
+++ b/cmd/deploy/env.go
@@ -0,0 +1,176 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/deploy/internal/config"
10 "github.com/bdw/deploy/internal/ssh"
11 "github.com/bdw/deploy/internal/state"
12)
13
14func runEnv(args []string) {
15 fs := flag.NewFlagSet("env", flag.ExitOnError)
16 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
17 var setVars envFlags
18 fs.Var(&setVars, "set", "Set environment variable (KEY=VALUE, can be specified multiple times)")
19 var unsetVars envFlags
20 fs.Var(&unsetVars, "unset", "Unset environment variable (KEY, can be specified multiple times)")
21 envFile := fs.String("file", "", "Load environment from file")
22 fs.Parse(args)
23
24 if len(fs.Args()) == 0 {
25 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
26 fmt.Fprintf(os.Stderr, "Usage: deploy env <app-name> [--set KEY=VALUE] [--unset KEY] [--file .env] --host user@vps-ip\n")
27 os.Exit(1)
28 }
29
30 name := fs.Args()[0]
31
32 // Get host from flag or config
33 if *host == "" {
34 cfg, err := config.Load()
35 if err != nil {
36 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
37 os.Exit(1)
38 }
39 *host = cfg.Host
40 }
41
42 if *host == "" {
43 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
44 fs.Usage()
45 os.Exit(1)
46 }
47
48 // Load state
49 st, err := state.Load()
50 if err != nil {
51 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
52 os.Exit(1)
53 }
54
55 // Get app info
56 app, err := st.GetApp(*host, name)
57 if err != nil {
58 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
59 os.Exit(1)
60 }
61
62 if app.Type != "app" {
63 fmt.Fprintf(os.Stderr, "Error: env is only available for apps, not static sites\n")
64 os.Exit(1)
65 }
66
67 // If no flags, just display current env (masked)
68 if len(setVars) == 0 && len(unsetVars) == 0 && *envFile == "" {
69 fmt.Printf("Environment variables for %s:\n\n", name)
70 if len(app.Env) == 0 {
71 fmt.Println(" (none)")
72 } else {
73 for k, v := range app.Env {
74 // Mask sensitive-looking values
75 display := v
76 if isSensitive(k) {
77 display = "***"
78 }
79 fmt.Printf(" %s=%s\n", k, display)
80 }
81 }
82 return
83 }
84
85 // Initialize env if nil
86 if app.Env == nil {
87 app.Env = make(map[string]string)
88 }
89
90 // Apply changes
91 changed := false
92
93 // Unset variables
94 for _, key := range unsetVars {
95 if _, exists := app.Env[key]; exists {
96 delete(app.Env, key)
97 changed = true
98 fmt.Printf("Unset %s\n", key)
99 }
100 }
101
102 // Set variables from flags
103 for _, e := range setVars {
104 parts := strings.SplitN(e, "=", 2)
105 if len(parts) == 2 {
106 app.Env[parts[0]] = parts[1]
107 changed = true
108 fmt.Printf("Set %s\n", parts[0])
109 }
110 }
111
112 // Set variables from file
113 if *envFile != "" {
114 fileEnv, err := parseEnvFile(*envFile)
115 if err != nil {
116 fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err)
117 os.Exit(1)
118 }
119 for k, v := range fileEnv {
120 app.Env[k] = v
121 changed = true
122 fmt.Printf("Set %s\n", k)
123 }
124 }
125
126 if !changed {
127 fmt.Println("No changes made")
128 return
129 }
130
131 // Save state
132 if err := st.Save(); err != nil {
133 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
134 os.Exit(1)
135 }
136
137 // Connect to VPS and update env file
138 client, err := ssh.Connect(*host)
139 if err != nil {
140 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
141 os.Exit(1)
142 }
143 defer client.Close()
144
145 // Regenerate env file
146 fmt.Println("→ Updating environment file on VPS...")
147 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
148 envContent := ""
149 for k, v := range app.Env {
150 envContent += fmt.Sprintf("%s=%s\n", k, v)
151 }
152 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
153 fmt.Fprintf(os.Stderr, "Error updating env file: %v\n", err)
154 os.Exit(1)
155 }
156
157 // Restart service to pick up new env
158 fmt.Println("→ Restarting service...")
159 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
160 fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err)
161 os.Exit(1)
162 }
163
164 fmt.Println("✓ Environment variables updated successfully")
165}
166
167func isSensitive(key string) bool {
168 key = strings.ToLower(key)
169 sensitiveWords := []string{"key", "secret", "password", "token", "api"}
170 for _, word := range sensitiveWords {
171 if strings.Contains(key, word) {
172 return true
173 }
174 }
175 return false
176}
diff --git a/cmd/deploy/init.go b/cmd/deploy/init.go
new file mode 100644
index 0000000..72c7d53
--- /dev/null
+++ b/cmd/deploy/init.go
@@ -0,0 +1,154 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/deploy/internal/config"
10 "github.com/bdw/deploy/internal/ssh"
11 "github.com/bdw/deploy/internal/state"
12)
13
14func runInit(args []string) {
15 fs := flag.NewFlagSet("init", flag.ExitOnError)
16 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
17 fs.Parse(args)
18
19 // Get host from flag or config
20 if *host == "" {
21 cfg, err := config.Load()
22 if err != nil {
23 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
24 os.Exit(1)
25 }
26 *host = cfg.Host
27 }
28
29 if *host == "" {
30 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
31 fs.Usage()
32 os.Exit(1)
33 }
34
35 fmt.Printf("Initializing VPS: %s\n", *host)
36
37 // Connect to VPS
38 client, err := ssh.Connect(*host)
39 if err != nil {
40 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
41 os.Exit(1)
42 }
43 defer client.Close()
44
45 // Detect OS
46 fmt.Println("→ Detecting OS...")
47 osRelease, err := client.Run("cat /etc/os-release")
48 if err != nil {
49 fmt.Fprintf(os.Stderr, "Error detecting OS: %v\n", err)
50 os.Exit(1)
51 }
52
53 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
54 fmt.Fprintf(os.Stderr, "Error: Unsupported OS (only Ubuntu and Debian are supported)\n")
55 os.Exit(1)
56 }
57 fmt.Println(" ✓ Detected Ubuntu/Debian")
58
59 // Check if Caddy is already installed
60 fmt.Println("→ Checking for Caddy...")
61 _, err = client.Run("which caddy")
62 if err == nil {
63 fmt.Println(" ✓ Caddy already installed")
64 } else {
65 // Install Caddy
66 fmt.Println(" Installing Caddy...")
67 installCaddy(client)
68 fmt.Println(" ✓ Caddy installed")
69 }
70
71 // Create Caddyfile
72 fmt.Println("→ Configuring Caddy...")
73 caddyfile := `{
74 email admin@example.com
75}
76
77import /etc/caddy/sites-enabled/*
78`
79 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
80 fmt.Fprintf(os.Stderr, "Error creating Caddyfile: %v\n", err)
81 os.Exit(1)
82 }
83 fmt.Println(" ✓ Caddyfile created")
84
85 // Create directories
86 fmt.Println("→ Creating directories...")
87 if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil {
88 fmt.Fprintf(os.Stderr, "Error creating /etc/deploy/env: %v\n", err)
89 os.Exit(1)
90 }
91 if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil {
92 fmt.Fprintf(os.Stderr, "Error creating /etc/caddy/sites-enabled: %v\n", err)
93 os.Exit(1)
94 }
95 fmt.Println(" ✓ Directories created")
96
97 // Enable and start Caddy
98 fmt.Println("→ Starting Caddy...")
99 if _, err := client.RunSudo("systemctl enable caddy"); err != nil {
100 fmt.Fprintf(os.Stderr, "Error enabling Caddy: %v\n", err)
101 os.Exit(1)
102 }
103 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
104 fmt.Fprintf(os.Stderr, "Error starting Caddy: %v\n", err)
105 os.Exit(1)
106 }
107 fmt.Println(" ✓ Caddy started")
108
109 // Verify Caddy is running
110 fmt.Println("→ Verifying installation...")
111 output, err := client.RunSudo("systemctl is-active caddy")
112 if err != nil || strings.TrimSpace(output) != "active" {
113 fmt.Fprintf(os.Stderr, "Warning: Caddy may not be running properly\n")
114 } else {
115 fmt.Println(" ✓ Caddy is active")
116 }
117
118 // Initialize local state if needed
119 st, err := state.Load()
120 if err != nil {
121 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
122 os.Exit(1)
123 }
124 st.GetHost(*host) // Ensure host exists in state
125 if err := st.Save(); err != nil {
126 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
127 os.Exit(1)
128 }
129
130 fmt.Println("\n✓ VPS initialized successfully!")
131 fmt.Println("\nNext steps:")
132 fmt.Println(" 1. Deploy a Go app:")
133 fmt.Printf(" deploy deploy --host %s --binary ./myapp --domain api.example.com\n", *host)
134 fmt.Println(" 2. Deploy a static site:")
135 fmt.Printf(" deploy deploy --host %s --static --dir ./dist --domain example.com\n", *host)
136}
137
138func installCaddy(client *ssh.Client) {
139 commands := []string{
140 "apt-get update",
141 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
142 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
143 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list",
144 "apt-get update",
145 "apt-get install -y caddy",
146 }
147
148 for _, cmd := range commands {
149 if _, err := client.RunSudo(cmd); err != nil {
150 fmt.Fprintf(os.Stderr, "Error running: %s\nError: %v\n", cmd, err)
151 os.Exit(1)
152 }
153 }
154}
diff --git a/cmd/deploy/list.go b/cmd/deploy/list.go
new file mode 100644
index 0000000..b74cf35
--- /dev/null
+++ b/cmd/deploy/list.go
@@ -0,0 +1,58 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7 "text/tabwriter"
8
9 "github.com/bdw/deploy/internal/config"
10 "github.com/bdw/deploy/internal/state"
11)
12
13func runList(args []string) {
14 fs := flag.NewFlagSet("list", flag.ExitOnError)
15 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
16 fs.Parse(args)
17
18 // Get host from flag or config
19 if *host == "" {
20 cfg, err := config.Load()
21 if err != nil {
22 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
23 os.Exit(1)
24 }
25 *host = cfg.Host
26 }
27
28 if *host == "" {
29 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
30 fs.Usage()
31 os.Exit(1)
32 }
33
34 // Load state
35 st, err := state.Load()
36 if err != nil {
37 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
38 os.Exit(1)
39 }
40
41 apps := st.ListApps(*host)
42 if len(apps) == 0 {
43 fmt.Printf("No deployments found for %s\n", *host)
44 return
45 }
46
47 // Print table
48 w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
49 fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT")
50 for name, app := range apps {
51 port := ""
52 if app.Type == "app" {
53 port = fmt.Sprintf(":%d", app.Port)
54 }
55 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port)
56 }
57 w.Flush()
58}
diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go
new file mode 100644
index 0000000..1589af3
--- /dev/null
+++ b/cmd/deploy/main.go
@@ -0,0 +1,82 @@
1package main
2
3import (
4 "fmt"
5 "os"
6)
7
8func main() {
9 if len(os.Args) < 2 {
10 printUsage()
11 os.Exit(1)
12 }
13
14 command := os.Args[1]
15
16 switch command {
17 case "init":
18 runInit(os.Args[2:])
19 case "deploy":
20 runDeploy(os.Args[2:])
21 case "list":
22 runList(os.Args[2:])
23 case "remove":
24 runRemove(os.Args[2:])
25 case "logs":
26 runLogs(os.Args[2:])
27 case "status":
28 runStatus(os.Args[2:])
29 case "restart":
30 runRestart(os.Args[2:])
31 case "env":
32 runEnv(os.Args[2:])
33 case "webui":
34 runWebUI(os.Args[2:])
35 case "help", "--help", "-h":
36 printUsage()
37 default:
38 fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command)
39 printUsage()
40 os.Exit(1)
41 }
42}
43
44func printUsage() {
45 usage := `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS
46
47USAGE:
48 deploy <command> [flags]
49
50COMMANDS:
51 init Initialize a fresh VPS (one-time setup)
52 deploy Deploy a Go app or static site
53 list List all deployed apps and sites
54 remove Remove a deployment
55 logs View logs for a deployment
56 status Check status of a deployment
57 restart Restart a deployment
58 env Manage environment variables
59 webui Launch web UI to manage deployments
60
61FLAGS:
62 Run 'deploy <command> -h' for command-specific flags
63
64EXAMPLES:
65 # Initialize VPS
66 deploy init --host user@vps-ip
67
68 # Deploy Go app
69 deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com
70
71 # Deploy static site
72 deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com
73
74 # List deployments
75 deploy list --host user@vps-ip
76
77CONFIG FILE:
78 Create ~/.config/deploy/config to set default host:
79 host: user@your-vps-ip
80`
81 fmt.Fprint(os.Stderr, usage)
82}
diff --git a/cmd/deploy/manage.go b/cmd/deploy/manage.go
new file mode 100644
index 0000000..3cee1f4
--- /dev/null
+++ b/cmd/deploy/manage.go
@@ -0,0 +1,327 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7
8 "github.com/bdw/deploy/internal/config"
9 "github.com/bdw/deploy/internal/ssh"
10 "github.com/bdw/deploy/internal/state"
11)
12
13func runRemove(args []string) {
14 fs := flag.NewFlagSet("remove", flag.ExitOnError)
15 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
16 fs.Parse(args)
17
18 if len(fs.Args()) == 0 {
19 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
20 fmt.Fprintf(os.Stderr, "Usage: deploy remove <app-name> --host user@vps-ip\n")
21 os.Exit(1)
22 }
23
24 name := fs.Args()[0]
25
26 // Get host from flag or config
27 if *host == "" {
28 cfg, err := config.Load()
29 if err != nil {
30 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
31 os.Exit(1)
32 }
33 *host = cfg.Host
34 }
35
36 if *host == "" {
37 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
38 fs.Usage()
39 os.Exit(1)
40 }
41
42 // Load state
43 st, err := state.Load()
44 if err != nil {
45 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
46 os.Exit(1)
47 }
48
49 // Get app info
50 app, err := st.GetApp(*host, name)
51 if err != nil {
52 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
53 os.Exit(1)
54 }
55
56 fmt.Printf("Removing deployment: %s\n", name)
57
58 // Connect to VPS
59 client, err := ssh.Connect(*host)
60 if err != nil {
61 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
62 os.Exit(1)
63 }
64 defer client.Close()
65
66 if app.Type == "app" {
67 // Stop and disable service
68 fmt.Println("→ Stopping service...")
69 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
70 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
71
72 // Remove systemd unit
73 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
74 client.RunSudo("systemctl daemon-reload")
75
76 // Remove binary
77 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
78
79 // Remove working directory
80 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
81
82 // Remove env file
83 client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name))
84
85 // Remove user
86 client.RunSudo(fmt.Sprintf("userdel %s", name))
87 } else {
88 // Remove static site files
89 fmt.Println("→ Removing files...")
90 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
91 }
92
93 // Remove Caddy config
94 fmt.Println("→ Removing Caddy config...")
95 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
96
97 // Reload Caddy
98 fmt.Println("→ Reloading Caddy...")
99 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
100 fmt.Fprintf(os.Stderr, "Warning: Error reloading Caddy: %v\n", err)
101 }
102
103 // Update state
104 if err := st.RemoveApp(*host, name); err != nil {
105 fmt.Fprintf(os.Stderr, "Error updating state: %v\n", err)
106 os.Exit(1)
107 }
108 if err := st.Save(); err != nil {
109 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
110 os.Exit(1)
111 }
112
113 fmt.Printf("✓ Deployment removed successfully\n")
114}
115
116func runLogs(args []string) {
117 fs := flag.NewFlagSet("logs", flag.ExitOnError)
118 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
119 follow := fs.Bool("f", false, "Follow logs")
120 lines := fs.Int("n", 50, "Number of lines to show")
121 fs.Parse(args)
122
123 if len(fs.Args()) == 0 {
124 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
125 fmt.Fprintf(os.Stderr, "Usage: deploy logs <app-name> --host user@vps-ip\n")
126 os.Exit(1)
127 }
128
129 name := fs.Args()[0]
130
131 // Get host from flag or config
132 if *host == "" {
133 cfg, err := config.Load()
134 if err != nil {
135 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
136 os.Exit(1)
137 }
138 *host = cfg.Host
139 }
140
141 if *host == "" {
142 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
143 fs.Usage()
144 os.Exit(1)
145 }
146
147 // Load state to verify app exists
148 st, err := state.Load()
149 if err != nil {
150 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
151 os.Exit(1)
152 }
153
154 app, err := st.GetApp(*host, name)
155 if err != nil {
156 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
157 os.Exit(1)
158 }
159
160 if app.Type != "app" {
161 fmt.Fprintf(os.Stderr, "Error: logs are only available for apps, not static sites\n")
162 os.Exit(1)
163 }
164
165 // Connect to VPS
166 client, err := ssh.Connect(*host)
167 if err != nil {
168 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
169 os.Exit(1)
170 }
171 defer client.Close()
172
173 // Build journalctl command
174 cmd := fmt.Sprintf("journalctl -u %s -n %d", name, *lines)
175 if *follow {
176 cmd += " -f"
177 }
178
179 // Run command
180 if *follow {
181 // Stream output for follow mode (no sudo needed for journalctl)
182 if err := client.RunStream(cmd); err != nil {
183 fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err)
184 os.Exit(1)
185 }
186 } else {
187 // Buffer output for non-follow mode (no sudo needed for journalctl)
188 output, err := client.Run(cmd)
189 if err != nil {
190 fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err)
191 os.Exit(1)
192 }
193 fmt.Print(output)
194 }
195}
196
197func runStatus(args []string) {
198 fs := flag.NewFlagSet("status", flag.ExitOnError)
199 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
200 fs.Parse(args)
201
202 if len(fs.Args()) == 0 {
203 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
204 fmt.Fprintf(os.Stderr, "Usage: deploy status <app-name> --host user@vps-ip\n")
205 os.Exit(1)
206 }
207
208 name := fs.Args()[0]
209
210 // Get host from flag or config
211 if *host == "" {
212 cfg, err := config.Load()
213 if err != nil {
214 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
215 os.Exit(1)
216 }
217 *host = cfg.Host
218 }
219
220 if *host == "" {
221 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
222 fs.Usage()
223 os.Exit(1)
224 }
225
226 // Load state to verify app exists
227 st, err := state.Load()
228 if err != nil {
229 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
230 os.Exit(1)
231 }
232
233 app, err := st.GetApp(*host, name)
234 if err != nil {
235 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
236 os.Exit(1)
237 }
238
239 if app.Type != "app" {
240 fmt.Fprintf(os.Stderr, "Error: status is only available for apps, not static sites\n")
241 os.Exit(1)
242 }
243
244 // Connect to VPS
245 client, err := ssh.Connect(*host)
246 if err != nil {
247 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
248 os.Exit(1)
249 }
250 defer client.Close()
251
252 // Get status
253 output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name))
254 if err != nil {
255 // systemctl status returns non-zero for non-active services
256 // but we still want to show the output
257 fmt.Print(output)
258 return
259 }
260
261 fmt.Print(output)
262}
263
264func runRestart(args []string) {
265 fs := flag.NewFlagSet("restart", flag.ExitOnError)
266 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
267 fs.Parse(args)
268
269 if len(fs.Args()) == 0 {
270 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
271 fmt.Fprintf(os.Stderr, "Usage: deploy restart <app-name> --host user@vps-ip\n")
272 os.Exit(1)
273 }
274
275 name := fs.Args()[0]
276
277 // Get host from flag or config
278 if *host == "" {
279 cfg, err := config.Load()
280 if err != nil {
281 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
282 os.Exit(1)
283 }
284 *host = cfg.Host
285 }
286
287 if *host == "" {
288 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
289 fs.Usage()
290 os.Exit(1)
291 }
292
293 // Load state to verify app exists
294 st, err := state.Load()
295 if err != nil {
296 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
297 os.Exit(1)
298 }
299
300 app, err := st.GetApp(*host, name)
301 if err != nil {
302 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
303 os.Exit(1)
304 }
305
306 if app.Type != "app" {
307 fmt.Fprintf(os.Stderr, "Error: restart is only available for apps, not static sites\n")
308 os.Exit(1)
309 }
310
311 // Connect to VPS
312 client, err := ssh.Connect(*host)
313 if err != nil {
314 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
315 os.Exit(1)
316 }
317 defer client.Close()
318
319 // Restart service
320 fmt.Printf("Restarting %s...\n", name)
321 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
322 fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err)
323 os.Exit(1)
324 }
325
326 fmt.Println("✓ Service restarted successfully")
327}
diff --git a/cmd/deploy/templates/webui.html b/cmd/deploy/templates/webui.html
new file mode 100644
index 0000000..052d599
--- /dev/null
+++ b/cmd/deploy/templates/webui.html
@@ -0,0 +1,440 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Deploy - Web UI</title>
7 <style>
8 * {
9 margin: 0;
10 padding: 0;
11 box-sizing: border-box;
12 }
13
14 body {
15 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
16 background: #f5f5f5;
17 color: #333;
18 line-height: 1.6;
19 }
20
21 header {
22 background: #2c3e50;
23 color: white;
24 padding: 1.5rem 2rem;
25 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
26 }
27
28 header h1 {
29 font-size: 1.8rem;
30 font-weight: 600;
31 }
32
33 header p {
34 color: #bdc3c7;
35 margin-top: 0.25rem;
36 font-size: 0.9rem;
37 }
38
39 .container {
40 max-width: 1200px;
41 margin: 2rem auto;
42 padding: 0 2rem;
43 }
44
45 .empty-state {
46 text-align: center;
47 padding: 4rem 2rem;
48 background: white;
49 border-radius: 8px;
50 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
51 }
52
53 .empty-state h2 {
54 color: #7f8c8d;
55 font-weight: 500;
56 margin-bottom: 0.5rem;
57 }
58
59 .empty-state p {
60 color: #95a5a6;
61 }
62
63 .host-section {
64 margin-bottom: 2rem;
65 }
66
67 .host-header {
68 background: white;
69 padding: 1rem 1.5rem;
70 border-radius: 8px 8px 0 0;
71 border-left: 4px solid #3498db;
72 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
73 }
74
75 .host-header h2 {
76 font-size: 1.3rem;
77 color: #2c3e50;
78 font-weight: 600;
79 }
80
81 .apps-grid {
82 display: grid;
83 grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
84 gap: 1rem;
85 padding: 1rem;
86 background: #ecf0f1;
87 border-radius: 0 0 8px 8px;
88 }
89
90 .app-card {
91 background: white;
92 padding: 1.5rem;
93 border-radius: 6px;
94 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
95 transition: transform 0.2s, box-shadow 0.2s;
96 }
97
98 .app-card:hover {
99 transform: translateY(-2px);
100 box-shadow: 0 4px 8px rgba(0,0,0,0.15);
101 }
102
103 .app-header {
104 display: flex;
105 justify-content: space-between;
106 align-items: center;
107 margin-bottom: 1rem;
108 }
109
110 .app-name {
111 font-size: 1.2rem;
112 font-weight: 600;
113 color: #2c3e50;
114 }
115
116 .app-type {
117 padding: 0.25rem 0.75rem;
118 border-radius: 12px;
119 font-size: 0.75rem;
120 font-weight: 500;
121 text-transform: uppercase;
122 }
123
124 .app-type.app {
125 background: #3498db;
126 color: white;
127 }
128
129 .app-type.static {
130 background: #2ecc71;
131 color: white;
132 }
133
134 .app-info {
135 margin-bottom: 0.5rem;
136 }
137
138 .app-info-label {
139 color: #7f8c8d;
140 font-size: 0.85rem;
141 font-weight: 500;
142 margin-bottom: 0.25rem;
143 }
144
145 .app-info-value {
146 color: #2c3e50;
147 font-family: 'Monaco', 'Courier New', monospace;
148 font-size: 0.9rem;
149 word-break: break-all;
150 }
151
152 .app-info-value a {
153 color: #3498db;
154 text-decoration: none;
155 }
156
157 .app-info-value a:hover {
158 text-decoration: underline;
159 }
160
161 .config-buttons {
162 margin-top: 1rem;
163 padding-top: 1rem;
164 border-top: 1px solid #ecf0f1;
165 display: flex;
166 gap: 0.5rem;
167 flex-wrap: wrap;
168 }
169
170 .config-btn {
171 padding: 0.4rem 0.8rem;
172 background: #3498db;
173 color: white;
174 border: none;
175 border-radius: 4px;
176 font-size: 0.8rem;
177 cursor: pointer;
178 transition: background 0.2s;
179 }
180
181 .config-btn:hover {
182 background: #2980b9;
183 }
184
185 .config-btn.secondary {
186 background: #95a5a6;
187 }
188
189 .config-btn.secondary:hover {
190 background: #7f8c8d;
191 }
192
193 .modal {
194 display: none;
195 position: fixed;
196 z-index: 1000;
197 left: 0;
198 top: 0;
199 width: 100%;
200 height: 100%;
201 overflow: auto;
202 background-color: rgba(0,0,0,0.6);
203 }
204
205 .modal.active {
206 display: block;
207 }
208
209 .modal-content {
210 background-color: #fefefe;
211 margin: 5% auto;
212 padding: 0;
213 border-radius: 8px;
214 width: 90%;
215 max-width: 900px;
216 max-height: 80vh;
217 display: flex;
218 flex-direction: column;
219 box-shadow: 0 4px 20px rgba(0,0,0,0.3);
220 }
221
222 .modal-header {
223 padding: 1.5rem;
224 border-bottom: 1px solid #ecf0f1;
225 display: flex;
226 justify-content: space-between;
227 align-items: center;
228 }
229
230 .modal-header h3 {
231 margin: 0;
232 color: #2c3e50;
233 }
234
235 .modal-path {
236 font-family: 'Monaco', 'Courier New', monospace;
237 font-size: 0.85rem;
238 color: #7f8c8d;
239 margin-top: 0.25rem;
240 }
241
242 .close {
243 color: #aaa;
244 font-size: 28px;
245 font-weight: bold;
246 cursor: pointer;
247 line-height: 1;
248 }
249
250 .close:hover {
251 color: #000;
252 }
253
254 .modal-body {
255 padding: 1.5rem;
256 overflow: auto;
257 flex: 1;
258 }
259
260 .config-content {
261 background: #282c34;
262 color: #abb2bf;
263 padding: 1rem;
264 border-radius: 4px;
265 font-family: 'Monaco', 'Courier New', monospace;
266 font-size: 0.85rem;
267 line-height: 1.5;
268 white-space: pre-wrap;
269 word-wrap: break-word;
270 overflow-x: auto;
271 text-align: left;
272 }
273
274 .loading {
275 text-align: center;
276 padding: 2rem;
277 color: #7f8c8d;
278 }
279
280 .refresh-info {
281 text-align: center;
282 color: #7f8c8d;
283 font-size: 0.9rem;
284 margin-top: 2rem;
285 padding: 1rem;
286 }
287 </style>
288</head>
289<body>
290 <header>
291 <h1>Deploy Web UI</h1>
292 <p>Manage your VPS deployments</p>
293 </header>
294
295 <div class="container">
296 {{if not .Hosts}}
297 <div class="empty-state">
298 <h2>No deployments found</h2>
299 <p>Use the CLI to deploy your first app or static site</p>
300 </div>
301 {{else}}
302 {{range .Hosts}}
303 <div class="host-section">
304 <div class="host-header">
305 <h2>{{.Host}}</h2>
306 </div>
307 <div class="apps-grid">
308 {{range .Apps}}
309 <div class="app-card">
310 <div class="app-header">
311 <div class="app-name">{{.Name}}</div>
312 <div class="app-type {{.Type}}">{{.Type}}</div>
313 </div>
314
315 <div class="app-info">
316 <div class="app-info-label">Domain</div>
317 <div class="app-info-value">
318 <a href="https://{{.Domain}}" target="_blank">{{.Domain}}</a>
319 </div>
320 </div>
321
322 {{if eq .Type "app"}}
323 <div class="app-info">
324 <div class="app-info-label">Port</div>
325 <div class="app-info-value">{{.Port}}</div>
326 </div>
327 {{end}}
328
329 <div class="config-buttons">
330 {{if eq .Type "app"}}
331 <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'systemd')">Systemd Unit</button>
332 {{end}}
333 <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'caddy')">Caddy Config</button>
334 {{if .Env}}
335 <button class="config-btn secondary" onclick="showConfig('{{.Host}}', '{{.Name}}', 'env')">Environment</button>
336 {{end}}
337 </div>
338 </div>
339 {{end}}
340 </div>
341 </div>
342 {{end}}
343 {{end}}
344
345 <div class="refresh-info">
346 Refresh the page to see latest changes
347 </div>
348 </div>
349
350 <!-- Modal -->
351 <div id="configModal" class="modal">
352 <div class="modal-content">
353 <div class="modal-header">
354 <div>
355 <h3 id="modalTitle">Configuration</h3>
356 <div class="modal-path" id="modalPath"></div>
357 </div>
358 <span class="close" onclick="closeModal()">&times;</span>
359 </div>
360 <div class="modal-body">
361 <div id="modalContent" class="loading">Loading...</div>
362 </div>
363 </div>
364 </div>
365
366 <script>
367 const modal = document.getElementById('configModal');
368 const modalTitle = document.getElementById('modalTitle');
369 const modalPath = document.getElementById('modalPath');
370 const modalContent = document.getElementById('modalContent');
371
372 function closeModal() {
373 modal.classList.remove('active');
374 }
375
376 window.onclick = function(event) {
377 if (event.target == modal) {
378 closeModal();
379 }
380 }
381
382 async function showConfig(host, app, type) {
383 modal.classList.add('active');
384 modalContent.innerHTML = '<div class="loading">Loading...</div>';
385
386 const titles = {
387 'systemd': 'Systemd Service Unit',
388 'caddy': 'Caddy Configuration',
389 'env': 'Environment Variables'
390 };
391
392 modalTitle.textContent = titles[type];
393
394 try {
395 const response = await fetch(`/api/configs?host=${encodeURIComponent(host)}&app=${encodeURIComponent(app)}`);
396 if (!response.ok) {
397 throw new Error(`HTTP error! status: ${response.status}`);
398 }
399 const configs = await response.json();
400
401 let content = '';
402 let path = '';
403
404 switch(type) {
405 case 'systemd':
406 content = configs.systemd || 'No systemd config available';
407 path = configs.systemdPath || '';
408 break;
409 case 'caddy':
410 content = configs.caddy || 'No Caddy config available';
411 path = configs.caddyPath || '';
412 break;
413 case 'env':
414 content = configs.env || 'No environment variables';
415 path = configs.envPath || '';
416 break;
417 }
418
419 modalPath.textContent = path;
420 modalContent.innerHTML = `<div class="config-content">${escapeHtml(content)}</div>`;
421 } catch (error) {
422 modalContent.innerHTML = `<div class="loading">Error loading config: ${error.message}</div>`;
423 }
424 }
425
426 function escapeHtml(text) {
427 const div = document.createElement('div');
428 div.textContent = text;
429 return div.innerHTML;
430 }
431
432 // Close modal with Escape key
433 document.addEventListener('keydown', function(event) {
434 if (event.key === 'Escape') {
435 closeModal();
436 }
437 });
438 </script>
439</body>
440</html>
diff --git a/cmd/deploy/webui.go b/cmd/deploy/webui.go
new file mode 100644
index 0000000..f57400e
--- /dev/null
+++ b/cmd/deploy/webui.go
@@ -0,0 +1,228 @@
1package main
2
3import (
4 "embed"
5 "encoding/json"
6 "flag"
7 "fmt"
8 "html/template"
9 "net/http"
10 "os"
11 "sort"
12 "strconv"
13
14 "github.com/bdw/deploy/internal/state"
15 "github.com/bdw/deploy/internal/templates"
16)
17
18//go:embed templates/*.html
19var templatesFS embed.FS
20
21func runWebUI(args []string) {
22 fs := flag.NewFlagSet("webui", flag.ExitOnError)
23 port := fs.String("port", "8080", "Port to run the web UI on")
24 help := fs.Bool("h", false, "Show help")
25
26 fs.Parse(args)
27
28 if *help {
29 fmt.Fprintf(os.Stderr, `Usage: deploy webui [flags]
30
31Launch a web interface to view and manage deployments.
32
33FLAGS:
34 -port string
35 Port to run the web UI on (default "8080")
36 -h Show this help message
37
38EXAMPLE:
39 # Launch web UI on default port (8080)
40 deploy webui
41
42 # Launch on custom port
43 deploy webui -port 3000
44`)
45 os.Exit(0)
46 }
47
48 // Parse template
49 tmpl, err := template.ParseFS(templatesFS, "templates/webui.html")
50 if err != nil {
51 fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err)
52 os.Exit(1)
53 }
54
55 // Handler for the main page
56 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
57 // Reload state on each request to show latest changes
58 st, err := state.Load()
59 if err != nil {
60 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
61 return
62 }
63
64 // Prepare data for template
65 type AppData struct {
66 Name string
67 Type string
68 Domain string
69 Port int
70 Env map[string]string
71 Host string
72 }
73
74 type HostData struct {
75 Host string
76 Apps []AppData
77 }
78
79 var hosts []HostData
80 for hostName, host := range st.Hosts {
81 var apps []AppData
82 for appName, app := range host.Apps {
83 apps = append(apps, AppData{
84 Name: appName,
85 Type: app.Type,
86 Domain: app.Domain,
87 Port: app.Port,
88 Env: app.Env,
89 Host: hostName,
90 })
91 }
92
93 // Sort apps by name
94 sort.Slice(apps, func(i, j int) bool {
95 return apps[i].Name < apps[j].Name
96 })
97
98 hosts = append(hosts, HostData{
99 Host: hostName,
100 Apps: apps,
101 })
102 }
103
104 // Sort hosts by name
105 sort.Slice(hosts, func(i, j int) bool {
106 return hosts[i].Host < hosts[j].Host
107 })
108
109 data := struct {
110 Hosts []HostData
111 }{
112 Hosts: hosts,
113 }
114
115 if err := tmpl.Execute(w, data); err != nil {
116 http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError)
117 return
118 }
119 })
120
121 // API endpoint to get state as JSON
122 http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) {
123 st, err := state.Load()
124 if err != nil {
125 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
126 return
127 }
128
129 w.Header().Set("Content-Type", "application/json")
130 json.NewEncoder(w).Encode(st)
131 })
132
133 // API endpoint to get rendered configs for an app
134 http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) {
135 host := r.URL.Query().Get("host")
136 appName := r.URL.Query().Get("app")
137
138 if host == "" || appName == "" {
139 http.Error(w, "Missing host or app parameter", http.StatusBadRequest)
140 return
141 }
142
143 st, err := state.Load()
144 if err != nil {
145 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
146 return
147 }
148
149 app, err := st.GetApp(host, appName)
150 if err != nil {
151 http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound)
152 return
153 }
154
155 configs := make(map[string]string)
156
157 // Render environment file
158 if app.Env != nil && len(app.Env) > 0 {
159 envContent := ""
160 for k, v := range app.Env {
161 envContent += fmt.Sprintf("%s=%s\n", k, v)
162 }
163 configs["env"] = envContent
164 configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName)
165 }
166
167 // Render configs based on app type
168 if app.Type == "app" {
169 // Render systemd service
170 workDir := fmt.Sprintf("/var/lib/%s", appName)
171 binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName)
172 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName)
173
174 serviceContent, err := templates.SystemdService(map[string]string{
175 "Name": appName,
176 "User": appName,
177 "WorkDir": workDir,
178 "BinaryPath": binaryPath,
179 "Port": strconv.Itoa(app.Port),
180 "EnvFile": envFilePath,
181 "Args": app.Args,
182 })
183 if err != nil {
184 http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError)
185 return
186 }
187 configs["systemd"] = serviceContent
188 configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName)
189
190 // Render Caddy config
191 caddyContent, err := templates.AppCaddy(map[string]string{
192 "Domain": app.Domain,
193 "Port": strconv.Itoa(app.Port),
194 })
195 if err != nil {
196 http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError)
197 return
198 }
199 configs["caddy"] = caddyContent
200 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
201 } else if app.Type == "static" {
202 // Render Caddy config for static site
203 remoteDir := fmt.Sprintf("/var/www/%s", appName)
204 caddyContent, err := templates.StaticCaddy(map[string]string{
205 "Domain": app.Domain,
206 "RootDir": remoteDir,
207 })
208 if err != nil {
209 http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError)
210 return
211 }
212 configs["caddy"] = caddyContent
213 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
214 }
215
216 w.Header().Set("Content-Type", "application/json")
217 json.NewEncoder(w).Encode(configs)
218 })
219
220 addr := fmt.Sprintf("localhost:%s", *port)
221 fmt.Printf("Starting web UI on http://%s\n", addr)
222 fmt.Printf("Press Ctrl+C to stop\n")
223
224 if err := http.ListenAndServe(addr, nil); err != nil {
225 fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
226 os.Exit(1)
227 }
228}