summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-01-23 20:54:46 -0800
committerbndw <ben@bdw.to>2026-01-23 20:54:46 -0800
commit98b9af372025595e8a4255538e2836e019311474 (patch)
tree0a26fa5a8a19ea8565da6d63e1f19c21fc170d12
parent7fcb9dfa87310e91b527829ece9989decb6fda64 (diff)
Add deploy command and fix static site naming
Static sites now default to using the domain as the name instead of the source directory basename, preventing conflicts when multiple sites use the same directory name (e.g., dist). Also fixes .gitignore to not exclude cmd/deploy/ directory.
-rw-r--r--.gitignore2
-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
9 files changed, 1960 insertions, 1 deletions
diff --git a/.gitignore b/.gitignore
index c23b850..e5cae05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
1# Binaries 1# Binaries
2deploy 2/deploy
3*.exe 3*.exe
4*.dll 4*.dll
5*.so 5*.so
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}