summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/deploy/deploy.go482
-rw-r--r--cmd/deploy/env.go170
-rw-r--r--cmd/deploy/env/env.go17
-rw-r--r--cmd/deploy/env/list.go69
-rw-r--r--cmd/deploy/env/set.go132
-rw-r--r--cmd/deploy/env/unset.go92
-rw-r--r--cmd/deploy/host/host.go18
-rw-r--r--cmd/deploy/host/init.go137
-rw-r--r--cmd/deploy/host/ssh.go45
-rw-r--r--cmd/deploy/host/status.go108
-rw-r--r--cmd/deploy/host/update.go80
-rw-r--r--cmd/deploy/init.go154
-rw-r--r--cmd/deploy/list.go36
-rw-r--r--cmd/deploy/logs.go75
-rw-r--r--cmd/deploy/main.go130
-rw-r--r--cmd/deploy/manage.go306
-rw-r--r--cmd/deploy/remove.go83
-rw-r--r--cmd/deploy/restart.go57
-rw-r--r--cmd/deploy/root.go377
-rw-r--r--cmd/deploy/status.go60
-rw-r--r--cmd/deploy/ui.go (renamed from cmd/deploy/webui.go)59
-rw-r--r--cmd/deploy/version.go17
-rw-r--r--cmd/deploy/vps.go229
-rw-r--r--go.mod11
-rw-r--r--go.sum10
25 files changed, 1469 insertions, 1485 deletions
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go
deleted file mode 100644
index 31aabe5..0000000
--- a/cmd/deploy/deploy.go
+++ /dev/null
@@ -1,482 +0,0 @@
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/ssh"
13 "github.com/bdw/deploy/internal/state"
14 "github.com/bdw/deploy/internal/templates"
15)
16
17type envFlags []string
18
19func (e *envFlags) String() string {
20 return strings.Join(*e, ",")
21}
22
23func (e *envFlags) Set(value string) error {
24 *e = append(*e, value)
25 return nil
26}
27
28type fileFlags []string
29
30func (f *fileFlags) String() string {
31 return strings.Join(*f, ",")
32}
33
34func (f *fileFlags) Set(value string) error {
35 *f = append(*f, value)
36 return nil
37}
38
39func runDeploy(args []string) {
40 fs := flag.NewFlagSet("deploy", flag.ExitOnError)
41 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
42 domain := fs.String("domain", "", "Domain name (required)")
43 name := fs.String("name", "", "App name (default: inferred from binary or directory)")
44 binary := fs.String("binary", "", "Path to Go binary (for app deployment)")
45 static := fs.Bool("static", false, "Deploy as static site")
46 dir := fs.String("dir", ".", "Directory to deploy (for static sites)")
47 port := fs.Int("port", 0, "Port override (default: auto-allocate)")
48 var envVars envFlags
49 fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)")
50 envFile := fs.String("env-file", "", "Path to .env file")
51 binaryArgs := fs.String("args", "", "Arguments to pass to binary")
52 var files fileFlags
53 fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)")
54
55 fs.Parse(args)
56
57 // Get host from flag or state default
58 if *host == "" {
59 st, err := state.Load()
60 if err != nil {
61 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
62 os.Exit(1)
63 }
64 *host = st.GetDefaultHost()
65 }
66
67 if *host == "" || *domain == "" {
68 fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n")
69 fs.Usage()
70 os.Exit(1)
71 }
72
73 if *static {
74 deployStatic(*host, *domain, *name, *dir)
75 } else {
76 deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files)
77 }
78}
79
80func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) {
81 // Require binary path
82 if binaryPath == "" {
83 fmt.Fprintf(os.Stderr, "Error: --binary is required\n")
84 os.Exit(1)
85 }
86
87 // Determine app name from binary if not specified
88 if name == "" {
89 name = filepath.Base(binaryPath)
90 }
91
92 // Verify binary exists
93 if _, err := os.Stat(binaryPath); err != nil {
94 fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath)
95 os.Exit(1)
96 }
97
98 fmt.Printf("Deploying app: %s\n", name)
99 fmt.Printf(" Domain: %s\n", domain)
100 fmt.Printf(" Binary: %s\n", binaryPath)
101
102 // Load state
103 st, err := state.Load()
104 if err != nil {
105 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
106 os.Exit(1)
107 }
108
109 // Check if app already exists (update) or new deployment
110 existingApp, _ := st.GetApp(host, name)
111 var port int
112 if existingApp != nil {
113 port = existingApp.Port
114 fmt.Printf(" Updating existing deployment (port %d)\n", port)
115 } else {
116 if portOverride > 0 {
117 port = portOverride
118 } else {
119 port = st.AllocatePort(host)
120 }
121 fmt.Printf(" Allocated port: %d\n", port)
122 }
123
124 // Parse environment variables
125 env := make(map[string]string)
126 if existingApp != nil {
127 // Preserve existing env vars
128 for k, v := range existingApp.Env {
129 env[k] = v
130 }
131 // Preserve existing args if not provided
132 if args == "" && existingApp.Args != "" {
133 args = existingApp.Args
134 }
135 // Preserve existing files if not provided
136 if len(files) == 0 && len(existingApp.Files) > 0 {
137 files = existingApp.Files
138 }
139 }
140
141 // Add/override from flags
142 for _, e := range envVars {
143 parts := strings.SplitN(e, "=", 2)
144 if len(parts) == 2 {
145 env[parts[0]] = parts[1]
146 }
147 }
148
149 // Add/override from file
150 if envFile != "" {
151 fileEnv, err := parseEnvFile(envFile)
152 if err != nil {
153 fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err)
154 os.Exit(1)
155 }
156 for k, v := range fileEnv {
157 env[k] = v
158 }
159 }
160
161 // Always set PORT
162 env["PORT"] = strconv.Itoa(port)
163
164 // Connect to VPS
165 client, err := ssh.Connect(host)
166 if err != nil {
167 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
168 os.Exit(1)
169 }
170 defer client.Close()
171
172 // Upload binary
173 fmt.Println("→ Uploading binary...")
174 remoteTmpPath := fmt.Sprintf("/tmp/%s", name)
175 if err := client.Upload(binaryPath, remoteTmpPath); err != nil {
176 fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err)
177 os.Exit(1)
178 }
179
180 // Create user (ignore error if already exists)
181 fmt.Println("→ Creating system user...")
182 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name))
183
184 // Create working directory
185 fmt.Println("→ Setting up directories...")
186 workDir := fmt.Sprintf("/var/lib/%s", name)
187 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
188 fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err)
189 os.Exit(1)
190 }
191 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil {
192 fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err)
193 os.Exit(1)
194 }
195
196 // Move binary to /usr/local/bin
197 fmt.Println("→ Installing binary...")
198 binaryDest := fmt.Sprintf("/usr/local/bin/%s", name)
199 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
200 fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err)
201 os.Exit(1)
202 }
203 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
204 fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err)
205 os.Exit(1)
206 }
207
208 // Upload config files to working directory
209 if len(files) > 0 {
210 fmt.Println("→ Uploading config files...")
211 for _, file := range files {
212 // Verify file exists locally
213 if _, err := os.Stat(file); err != nil {
214 fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file)
215 os.Exit(1)
216 }
217
218 // Determine remote path (preserve filename)
219 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
220 remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file))
221
222 // Upload to tmp first
223 if err := client.Upload(file, remoteTmpPath); err != nil {
224 fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err)
225 os.Exit(1)
226 }
227
228 // Move to working directory
229 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil {
230 fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err)
231 os.Exit(1)
232 }
233
234 // Set ownership
235 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil {
236 fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err)
237 os.Exit(1)
238 }
239
240 fmt.Printf(" Uploaded: %s\n", file)
241 }
242 }
243
244 // Create env file
245 fmt.Println("→ Creating environment file...")
246 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
247 envContent := ""
248 for k, v := range env {
249 envContent += fmt.Sprintf("%s=%s\n", k, v)
250 }
251 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
252 fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err)
253 os.Exit(1)
254 }
255 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
256 fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err)
257 os.Exit(1)
258 }
259 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil {
260 fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err)
261 os.Exit(1)
262 }
263
264 // Generate systemd unit
265 fmt.Println("→ Creating systemd service...")
266 serviceContent, err := templates.SystemdService(map[string]string{
267 "Name": name,
268 "User": name,
269 "WorkDir": workDir,
270 "BinaryPath": binaryDest,
271 "Port": strconv.Itoa(port),
272 "EnvFile": envFilePath,
273 "Args": args,
274 })
275 if err != nil {
276 fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err)
277 os.Exit(1)
278 }
279
280 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
281 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
282 fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err)
283 os.Exit(1)
284 }
285
286 // Generate Caddy config
287 fmt.Println("→ Configuring Caddy...")
288 caddyContent, err := templates.AppCaddy(map[string]string{
289 "Domain": domain,
290 "Port": strconv.Itoa(port),
291 })
292 if err != nil {
293 fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err)
294 os.Exit(1)
295 }
296
297 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
298 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
299 fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err)
300 os.Exit(1)
301 }
302
303 // Reload systemd
304 fmt.Println("→ Reloading systemd...")
305 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
306 fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err)
307 os.Exit(1)
308 }
309
310 // Enable and start service
311 fmt.Println("→ Starting service...")
312 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil {
313 fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err)
314 os.Exit(1)
315 }
316 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
317 fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err)
318 os.Exit(1)
319 }
320
321 // Reload Caddy
322 fmt.Println("→ Reloading Caddy...")
323 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
324 fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err)
325 os.Exit(1)
326 }
327
328 // Update state
329 st.AddApp(host, name, &state.App{
330 Type: "app",
331 Domain: domain,
332 Port: port,
333 Env: env,
334 Args: args,
335 Files: files,
336 })
337 if err := st.Save(); err != nil {
338 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
339 os.Exit(1)
340 }
341
342 fmt.Printf("\n✓ App deployed successfully!\n")
343 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
344}
345
346func deployStatic(host, domain, name, dir string) {
347 // Determine site name (default to domain to avoid conflicts)
348 if name == "" {
349 name = domain
350 }
351
352 // Verify directory exists
353 if _, err := os.Stat(dir); err != nil {
354 fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir)
355 os.Exit(1)
356 }
357
358 fmt.Printf("Deploying static site: %s\n", name)
359 fmt.Printf(" Domain: %s\n", domain)
360 fmt.Printf(" Directory: %s\n", dir)
361
362 // Load state
363 st, err := state.Load()
364 if err != nil {
365 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
366 os.Exit(1)
367 }
368
369 // Connect to VPS
370 client, err := ssh.Connect(host)
371 if err != nil {
372 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
373 os.Exit(1)
374 }
375 defer client.Close()
376
377 // Create remote directory
378 remoteDir := fmt.Sprintf("/var/www/%s", name)
379 fmt.Println("→ Creating remote directory...")
380 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
381 fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err)
382 os.Exit(1)
383 }
384
385 // Get current user for temporary ownership during upload
386 currentUser, err := client.Run("whoami")
387 if err != nil {
388 fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err)
389 os.Exit(1)
390 }
391 currentUser = strings.TrimSpace(currentUser)
392
393 // Set ownership to current user for upload
394 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
395 fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err)
396 os.Exit(1)
397 }
398
399 // Upload files
400 fmt.Println("→ Uploading files...")
401 if err := client.UploadDir(dir, remoteDir); err != nil {
402 fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err)
403 os.Exit(1)
404 }
405
406 // Set ownership and permissions
407 fmt.Println("→ Setting permissions...")
408 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
409 fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err)
410 os.Exit(1)
411 }
412 // Make files readable by all (755 for dirs, 644 for files)
413 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
414 fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err)
415 os.Exit(1)
416 }
417 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
418 fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err)
419 os.Exit(1)
420 }
421
422 // Generate Caddy config
423 fmt.Println("→ Configuring Caddy...")
424 caddyContent, err := templates.StaticCaddy(map[string]string{
425 "Domain": domain,
426 "RootDir": remoteDir,
427 })
428 if err != nil {
429 fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err)
430 os.Exit(1)
431 }
432
433 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
434 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
435 fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err)
436 os.Exit(1)
437 }
438
439 // Reload Caddy
440 fmt.Println("→ Reloading Caddy...")
441 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
442 fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err)
443 os.Exit(1)
444 }
445
446 // Update state
447 st.AddApp(host, name, &state.App{
448 Type: "static",
449 Domain: domain,
450 })
451 if err := st.Save(); err != nil {
452 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
453 os.Exit(1)
454 }
455
456 fmt.Printf("\n✓ Static site deployed successfully!\n")
457 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
458}
459
460func parseEnvFile(path string) (map[string]string, error) {
461 file, err := os.Open(path)
462 if err != nil {
463 return nil, err
464 }
465 defer file.Close()
466
467 env := make(map[string]string)
468 scanner := bufio.NewScanner(file)
469 for scanner.Scan() {
470 line := strings.TrimSpace(scanner.Text())
471 if line == "" || strings.HasPrefix(line, "#") {
472 continue
473 }
474
475 parts := strings.SplitN(line, "=", 2)
476 if len(parts) == 2 {
477 env[parts[0]] = parts[1]
478 }
479 }
480
481 return env, scanner.Err()
482}
diff --git a/cmd/deploy/env.go b/cmd/deploy/env.go
deleted file mode 100644
index a43cd6a..0000000
--- a/cmd/deploy/env.go
+++ /dev/null
@@ -1,170 +0,0 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/deploy/internal/ssh"
10 "github.com/bdw/deploy/internal/state"
11)
12
13func runEnv(args []string) {
14 fs := flag.NewFlagSet("env", flag.ExitOnError)
15 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
16 var setVars envFlags
17 fs.Var(&setVars, "set", "Set environment variable (KEY=VALUE, can be specified multiple times)")
18 var unsetVars envFlags
19 fs.Var(&unsetVars, "unset", "Unset environment variable (KEY, can be specified multiple times)")
20 envFile := fs.String("file", "", "Load environment from file")
21 fs.Parse(args)
22
23 if len(fs.Args()) == 0 {
24 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
25 fmt.Fprintf(os.Stderr, "Usage: deploy env <app-name> [--set KEY=VALUE] [--unset KEY] [--file .env] --host user@vps-ip\n")
26 os.Exit(1)
27 }
28
29 name := fs.Args()[0]
30
31 // Load state
32 st, err := state.Load()
33 if err != nil {
34 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
35 os.Exit(1)
36 }
37
38 // Get host from flag or state default
39 if *host == "" {
40 *host = st.GetDefaultHost()
41 }
42
43 if *host == "" {
44 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
45 fs.Usage()
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 if app.Type != "app" {
57 fmt.Fprintf(os.Stderr, "Error: env is only available for apps, not static sites\n")
58 os.Exit(1)
59 }
60
61 // If no flags, just display current env (masked)
62 if len(setVars) == 0 && len(unsetVars) == 0 && *envFile == "" {
63 fmt.Printf("Environment variables for %s:\n\n", name)
64 if len(app.Env) == 0 {
65 fmt.Println(" (none)")
66 } else {
67 for k, v := range app.Env {
68 // Mask sensitive-looking values
69 display := v
70 if isSensitive(k) {
71 display = "***"
72 }
73 fmt.Printf(" %s=%s\n", k, display)
74 }
75 }
76 return
77 }
78
79 // Initialize env if nil
80 if app.Env == nil {
81 app.Env = make(map[string]string)
82 }
83
84 // Apply changes
85 changed := false
86
87 // Unset variables
88 for _, key := range unsetVars {
89 if _, exists := app.Env[key]; exists {
90 delete(app.Env, key)
91 changed = true
92 fmt.Printf("Unset %s\n", key)
93 }
94 }
95
96 // Set variables from flags
97 for _, e := range setVars {
98 parts := strings.SplitN(e, "=", 2)
99 if len(parts) == 2 {
100 app.Env[parts[0]] = parts[1]
101 changed = true
102 fmt.Printf("Set %s\n", parts[0])
103 }
104 }
105
106 // Set variables from file
107 if *envFile != "" {
108 fileEnv, err := parseEnvFile(*envFile)
109 if err != nil {
110 fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err)
111 os.Exit(1)
112 }
113 for k, v := range fileEnv {
114 app.Env[k] = v
115 changed = true
116 fmt.Printf("Set %s\n", k)
117 }
118 }
119
120 if !changed {
121 fmt.Println("No changes made")
122 return
123 }
124
125 // Save state
126 if err := st.Save(); err != nil {
127 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
128 os.Exit(1)
129 }
130
131 // Connect to VPS and update env file
132 client, err := ssh.Connect(*host)
133 if err != nil {
134 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
135 os.Exit(1)
136 }
137 defer client.Close()
138
139 // Regenerate env file
140 fmt.Println("→ Updating environment file on VPS...")
141 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
142 envContent := ""
143 for k, v := range app.Env {
144 envContent += fmt.Sprintf("%s=%s\n", k, v)
145 }
146 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
147 fmt.Fprintf(os.Stderr, "Error updating env file: %v\n", err)
148 os.Exit(1)
149 }
150
151 // Restart service to pick up new env
152 fmt.Println("→ Restarting service...")
153 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
154 fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err)
155 os.Exit(1)
156 }
157
158 fmt.Println("✓ Environment variables updated successfully")
159}
160
161func isSensitive(key string) bool {
162 key = strings.ToLower(key)
163 sensitiveWords := []string{"key", "secret", "password", "token", "api"}
164 for _, word := range sensitiveWords {
165 if strings.Contains(key, word) {
166 return true
167 }
168 }
169 return false
170}
diff --git a/cmd/deploy/env/env.go b/cmd/deploy/env/env.go
new file mode 100644
index 0000000..489353a
--- /dev/null
+++ b/cmd/deploy/env/env.go
@@ -0,0 +1,17 @@
1package env
2
3import (
4 "github.com/spf13/cobra"
5)
6
7var Cmd = &cobra.Command{
8 Use: "env",
9 Short: "Manage environment variables",
10 Long: "Manage environment variables for deployed applications",
11}
12
13func init() {
14 Cmd.AddCommand(listCmd)
15 Cmd.AddCommand(setCmd)
16 Cmd.AddCommand(unsetCmd)
17}
diff --git a/cmd/deploy/env/list.go b/cmd/deploy/env/list.go
new file mode 100644
index 0000000..af92171
--- /dev/null
+++ b/cmd/deploy/env/list.go
@@ -0,0 +1,69 @@
1package env
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var listCmd = &cobra.Command{
12 Use: "list <app>",
13 Short: "List environment variables for an app",
14 Args: cobra.ExactArgs(1),
15 RunE: runList,
16}
17
18func runList(cmd *cobra.Command, args []string) error {
19 name := args[0]
20
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host, _ := cmd.Flags().GetString("host")
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30
31 if host == "" {
32 return fmt.Errorf("--host is required")
33 }
34
35 app, err := st.GetApp(host, name)
36 if err != nil {
37 return err
38 }
39
40 if app.Type != "app" {
41 return fmt.Errorf("env is only available for apps, not static sites")
42 }
43
44 fmt.Printf("Environment variables for %s:\n\n", name)
45 if len(app.Env) == 0 {
46 fmt.Println(" (none)")
47 } else {
48 for k, v := range app.Env {
49 display := v
50 if isSensitive(k) {
51 display = "***"
52 }
53 fmt.Printf(" %s=%s\n", k, display)
54 }
55 }
56
57 return nil
58}
59
60func isSensitive(key string) bool {
61 key = strings.ToLower(key)
62 sensitiveWords := []string{"key", "secret", "password", "token", "api"}
63 for _, word := range sensitiveWords {
64 if strings.Contains(key, word) {
65 return true
66 }
67 }
68 return false
69}
diff --git a/cmd/deploy/env/set.go b/cmd/deploy/env/set.go
new file mode 100644
index 0000000..35d77ff
--- /dev/null
+++ b/cmd/deploy/env/set.go
@@ -0,0 +1,132 @@
1package env
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/deploy/internal/ssh"
10 "github.com/bdw/deploy/internal/state"
11 "github.com/spf13/cobra"
12)
13
14var setCmd = &cobra.Command{
15 Use: "set <app> KEY=VALUE...",
16 Short: "Set environment variable(s)",
17 Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.",
18 Args: cobra.MinimumNArgs(2),
19 RunE: runSet,
20}
21
22func init() {
23 setCmd.Flags().StringP("file", "f", "", "Load environment from file")
24}
25
26func runSet(cmd *cobra.Command, args []string) error {
27 name := args[0]
28 envVars := args[1:]
29
30 st, err := state.Load()
31 if err != nil {
32 return fmt.Errorf("error loading state: %w", err)
33 }
34
35 host, _ := cmd.Flags().GetString("host")
36 if host == "" {
37 host = st.GetDefaultHost()
38 }
39
40 if host == "" {
41 return fmt.Errorf("--host is required")
42 }
43
44 app, err := st.GetApp(host, name)
45 if err != nil {
46 return err
47 }
48
49 if app.Type != "app" {
50 return fmt.Errorf("env is only available for apps, not static sites")
51 }
52
53 if app.Env == nil {
54 app.Env = make(map[string]string)
55 }
56
57 // Set variables from args
58 for _, e := range envVars {
59 parts := strings.SplitN(e, "=", 2)
60 if len(parts) == 2 {
61 app.Env[parts[0]] = parts[1]
62 fmt.Printf("Set %s\n", parts[0])
63 } else {
64 return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e)
65 }
66 }
67
68 // Set variables from file if provided
69 envFile, _ := cmd.Flags().GetString("file")
70 if envFile != "" {
71 fileEnv, err := parseEnvFile(envFile)
72 if err != nil {
73 return fmt.Errorf("error reading env file: %w", err)
74 }
75 for k, v := range fileEnv {
76 app.Env[k] = v
77 fmt.Printf("Set %s\n", k)
78 }
79 }
80
81 if err := st.Save(); err != nil {
82 return fmt.Errorf("error saving state: %w", err)
83 }
84
85 client, err := ssh.Connect(host)
86 if err != nil {
87 return fmt.Errorf("error connecting to VPS: %w", err)
88 }
89 defer client.Close()
90
91 fmt.Println("-> Updating environment file on VPS...")
92 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
93 envContent := ""
94 for k, v := range app.Env {
95 envContent += fmt.Sprintf("%s=%s\n", k, v)
96 }
97 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
98 return fmt.Errorf("error updating env file: %w", err)
99 }
100
101 fmt.Println("-> Restarting service...")
102 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
103 return fmt.Errorf("error restarting service: %w", err)
104 }
105
106 fmt.Println("Environment variables updated successfully")
107 return nil
108}
109
110func parseEnvFile(path string) (map[string]string, error) {
111 file, err := os.Open(path)
112 if err != nil {
113 return nil, err
114 }
115 defer file.Close()
116
117 env := make(map[string]string)
118 scanner := bufio.NewScanner(file)
119 for scanner.Scan() {
120 line := strings.TrimSpace(scanner.Text())
121 if line == "" || strings.HasPrefix(line, "#") {
122 continue
123 }
124
125 parts := strings.SplitN(line, "=", 2)
126 if len(parts) == 2 {
127 env[parts[0]] = parts[1]
128 }
129 }
130
131 return env, scanner.Err()
132}
diff --git a/cmd/deploy/env/unset.go b/cmd/deploy/env/unset.go
new file mode 100644
index 0000000..65a8986
--- /dev/null
+++ b/cmd/deploy/env/unset.go
@@ -0,0 +1,92 @@
1package env
2
3import (
4 "fmt"
5
6 "github.com/bdw/deploy/internal/ssh"
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var unsetCmd = &cobra.Command{
12 Use: "unset <app> KEY...",
13 Short: "Unset environment variable(s)",
14 Long: "Remove one or more environment variables from an app.",
15 Args: cobra.MinimumNArgs(2),
16 RunE: runUnset,
17}
18
19func runUnset(cmd *cobra.Command, args []string) error {
20 name := args[0]
21 keys := args[1:]
22
23 st, err := state.Load()
24 if err != nil {
25 return fmt.Errorf("error loading state: %w", err)
26 }
27
28 host, _ := cmd.Flags().GetString("host")
29 if host == "" {
30 host = st.GetDefaultHost()
31 }
32
33 if host == "" {
34 return fmt.Errorf("--host is required")
35 }
36
37 app, err := st.GetApp(host, name)
38 if err != nil {
39 return err
40 }
41
42 if app.Type != "app" {
43 return fmt.Errorf("env is only available for apps, not static sites")
44 }
45
46 if app.Env == nil {
47 return fmt.Errorf("no environment variables set")
48 }
49
50 changed := false
51 for _, key := range keys {
52 if _, exists := app.Env[key]; exists {
53 delete(app.Env, key)
54 changed = true
55 fmt.Printf("Unset %s\n", key)
56 } else {
57 fmt.Printf("Warning: %s not found\n", key)
58 }
59 }
60
61 if !changed {
62 return nil
63 }
64
65 if err := st.Save(); err != nil {
66 return fmt.Errorf("error saving state: %w", err)
67 }
68
69 client, err := ssh.Connect(host)
70 if err != nil {
71 return fmt.Errorf("error connecting to VPS: %w", err)
72 }
73 defer client.Close()
74
75 fmt.Println("-> Updating environment file on VPS...")
76 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
77 envContent := ""
78 for k, v := range app.Env {
79 envContent += fmt.Sprintf("%s=%s\n", k, v)
80 }
81 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
82 return fmt.Errorf("error updating env file: %w", err)
83 }
84
85 fmt.Println("-> Restarting service...")
86 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
87 return fmt.Errorf("error restarting service: %w", err)
88 }
89
90 fmt.Println("Environment variables updated successfully")
91 return nil
92}
diff --git a/cmd/deploy/host/host.go b/cmd/deploy/host/host.go
new file mode 100644
index 0000000..603a946
--- /dev/null
+++ b/cmd/deploy/host/host.go
@@ -0,0 +1,18 @@
1package host
2
3import (
4 "github.com/spf13/cobra"
5)
6
7var Cmd = &cobra.Command{
8 Use: "host",
9 Short: "Manage VPS host",
10 Long: "Commands for managing and monitoring the VPS host",
11}
12
13func init() {
14 Cmd.AddCommand(initCmd)
15 Cmd.AddCommand(statusCmd)
16 Cmd.AddCommand(updateCmd)
17 Cmd.AddCommand(sshCmd)
18}
diff --git a/cmd/deploy/host/init.go b/cmd/deploy/host/init.go
new file mode 100644
index 0000000..984e5d3
--- /dev/null
+++ b/cmd/deploy/host/init.go
@@ -0,0 +1,137 @@
1package host
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/deploy/internal/ssh"
8 "github.com/bdw/deploy/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var initCmd = &cobra.Command{
13 Use: "init",
14 Short: "Initialize VPS (one-time setup)",
15 Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories",
16 RunE: runInit,
17}
18
19func runInit(cmd *cobra.Command, args []string) error {
20 st, err := state.Load()
21 if err != nil {
22 return fmt.Errorf("error loading state: %w", err)
23 }
24
25 host, _ := cmd.Flags().GetString("host")
26 if host == "" {
27 host = st.GetDefaultHost()
28 }
29
30 if host == "" {
31 return fmt.Errorf("--host is required")
32 }
33
34 fmt.Printf("Initializing VPS: %s\n", host)
35
36 client, err := ssh.Connect(host)
37 if err != nil {
38 return fmt.Errorf("error connecting to VPS: %w", err)
39 }
40 defer client.Close()
41
42 fmt.Println("-> Detecting OS...")
43 osRelease, err := client.Run("cat /etc/os-release")
44 if err != nil {
45 return fmt.Errorf("error detecting OS: %w", err)
46 }
47
48 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
49 return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)")
50 }
51 fmt.Println(" Detected Ubuntu/Debian")
52
53 fmt.Println("-> Checking for Caddy...")
54 _, err = client.Run("which caddy")
55 if err == nil {
56 fmt.Println(" Caddy already installed")
57 } else {
58 fmt.Println(" Installing Caddy...")
59 if err := installCaddy(client); err != nil {
60 return err
61 }
62 fmt.Println(" Caddy installed")
63 }
64
65 fmt.Println("-> Configuring Caddy...")
66 caddyfile := `{
67 email admin@example.com
68}
69
70import /etc/caddy/sites-enabled/*
71`
72 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
73 return fmt.Errorf("error creating Caddyfile: %w", err)
74 }
75 fmt.Println(" Caddyfile created")
76
77 fmt.Println("-> Creating directories...")
78 if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil {
79 return fmt.Errorf("error creating /etc/deploy/env: %w", err)
80 }
81 if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil {
82 return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err)
83 }
84 fmt.Println(" Directories created")
85
86 fmt.Println("-> Starting Caddy...")
87 if _, err := client.RunSudo("systemctl enable caddy"); err != nil {
88 return fmt.Errorf("error enabling Caddy: %w", err)
89 }
90 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
91 return fmt.Errorf("error starting Caddy: %w", err)
92 }
93 fmt.Println(" Caddy started")
94
95 fmt.Println("-> Verifying installation...")
96 output, err := client.RunSudo("systemctl is-active caddy")
97 if err != nil || strings.TrimSpace(output) != "active" {
98 fmt.Println(" Warning: Caddy may not be running properly")
99 } else {
100 fmt.Println(" Caddy is active")
101 }
102
103 st.GetHost(host)
104 if st.GetDefaultHost() == "" {
105 st.SetDefaultHost(host)
106 fmt.Printf(" Set %s as default host\n", host)
107 }
108 if err := st.Save(); err != nil {
109 return fmt.Errorf("error saving state: %w", err)
110 }
111
112 fmt.Println("\nVPS initialized successfully!")
113 fmt.Println("\nNext steps:")
114 fmt.Println(" 1. Deploy a Go app:")
115 fmt.Printf(" deploy --binary ./myapp --domain api.example.com\n")
116 fmt.Println(" 2. Deploy a static site:")
117 fmt.Printf(" deploy --static --dir ./dist --domain example.com\n")
118 return nil
119}
120
121func installCaddy(client *ssh.Client) error {
122 commands := []string{
123 "apt-get update",
124 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
125 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
126 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list",
127 "apt-get update",
128 "apt-get install -y caddy",
129 }
130
131 for _, cmd := range commands {
132 if _, err := client.RunSudo(cmd); err != nil {
133 return fmt.Errorf("error running: %s: %w", cmd, err)
134 }
135 }
136 return nil
137}
diff --git a/cmd/deploy/host/ssh.go b/cmd/deploy/host/ssh.go
new file mode 100644
index 0000000..a33986f
--- /dev/null
+++ b/cmd/deploy/host/ssh.go
@@ -0,0 +1,45 @@
1package host
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7
8 "github.com/bdw/deploy/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var sshCmd = &cobra.Command{
13 Use: "ssh",
14 Short: "Open interactive SSH session",
15 RunE: runSSH,
16}
17
18func runSSH(cmd *cobra.Command, args []string) error {
19 st, err := state.Load()
20 if err != nil {
21 return fmt.Errorf("error loading state: %w", err)
22 }
23
24 host, _ := cmd.Flags().GetString("host")
25 if host == "" {
26 host = st.GetDefaultHost()
27 }
28
29 if host == "" {
30 return fmt.Errorf("--host is required (no default host set)")
31 }
32
33 sshCmd := exec.Command("ssh", host)
34 sshCmd.Stdin = os.Stdin
35 sshCmd.Stdout = os.Stdout
36 sshCmd.Stderr = os.Stderr
37
38 if err := sshCmd.Run(); err != nil {
39 if exitErr, ok := err.(*exec.ExitError); ok {
40 os.Exit(exitErr.ExitCode())
41 }
42 return err
43 }
44 return nil
45}
diff --git a/cmd/deploy/host/status.go b/cmd/deploy/host/status.go
new file mode 100644
index 0000000..bdd9c31
--- /dev/null
+++ b/cmd/deploy/host/status.go
@@ -0,0 +1,108 @@
1package host
2
3import (
4 "fmt"
5
6 "github.com/bdw/deploy/internal/ssh"
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var statusCmd = &cobra.Command{
12 Use: "status",
13 Short: "Show VPS health (uptime, disk, memory)",
14 RunE: runStatus,
15}
16
17func runStatus(cmd *cobra.Command, args []string) error {
18 st, err := state.Load()
19 if err != nil {
20 return fmt.Errorf("error loading state: %w", err)
21 }
22
23 host, _ := cmd.Flags().GetString("host")
24 if host == "" {
25 host = st.GetDefaultHost()
26 }
27
28 if host == "" {
29 return fmt.Errorf("--host is required (no default host set)")
30 }
31
32 fmt.Printf("Connecting to %s...\n\n", host)
33
34 client, err := ssh.Connect(host)
35 if err != nil {
36 return fmt.Errorf("error connecting to VPS: %w", err)
37 }
38 defer client.Close()
39
40 fmt.Println("UPTIME")
41 if output, err := client.Run("uptime -p"); err == nil {
42 fmt.Printf(" %s", output)
43 }
44 if output, err := client.Run("uptime -s"); err == nil {
45 fmt.Printf(" Since: %s", output)
46 }
47 fmt.Println()
48
49 fmt.Println("LOAD")
50 if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil {
51 fmt.Printf(" 1m, 5m, 15m: %s", output)
52 }
53 fmt.Println()
54
55 fmt.Println("MEMORY")
56 if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil {
57 fmt.Print(output)
58 }
59 if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil {
60 fmt.Print(output)
61 }
62 fmt.Println()
63
64 fmt.Println("DISK")
65 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil {
66 fmt.Print(output)
67 }
68 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil {
69 fmt.Print(output)
70 }
71 fmt.Println()
72
73 fmt.Println("UPDATES")
74 if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil {
75 fmt.Print(output)
76 }
77 fmt.Println()
78
79 fmt.Println("SERVICES")
80 if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil {
81 if output == "active\n" {
82 fmt.Println(" Caddy: active")
83 } else {
84 fmt.Println(" Caddy: inactive")
85 }
86 }
87
88 hostState := st.GetHost(host)
89 if hostState != nil && len(hostState.Apps) > 0 {
90 activeCount := 0
91 for name, app := range hostState.Apps {
92 if app.Type == "app" {
93 if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" {
94 activeCount++
95 }
96 }
97 }
98 appCount := 0
99 for _, app := range hostState.Apps {
100 if app.Type == "app" {
101 appCount++
102 }
103 }
104 fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount)
105 }
106
107 return nil
108}
diff --git a/cmd/deploy/host/update.go b/cmd/deploy/host/update.go
new file mode 100644
index 0000000..6f1b43b
--- /dev/null
+++ b/cmd/deploy/host/update.go
@@ -0,0 +1,80 @@
1package host
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/deploy/internal/ssh"
10 "github.com/bdw/deploy/internal/state"
11 "github.com/spf13/cobra"
12)
13
14var updateCmd = &cobra.Command{
15 Use: "update",
16 Short: "Update VPS packages",
17 Long: "Run apt update && apt upgrade on the VPS",
18 RunE: runUpdate,
19}
20
21func init() {
22 updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
23}
24
25func runUpdate(cmd *cobra.Command, args []string) error {
26 st, err := state.Load()
27 if err != nil {
28 return fmt.Errorf("error loading state: %w", err)
29 }
30
31 host, _ := cmd.Flags().GetString("host")
32 if host == "" {
33 host = st.GetDefaultHost()
34 }
35
36 if host == "" {
37 return fmt.Errorf("--host is required (no default host set)")
38 }
39
40 yes, _ := cmd.Flags().GetBool("yes")
41 if !yes {
42 fmt.Printf("This will run apt update && apt upgrade on %s\n", host)
43 fmt.Print("Continue? [y/N]: ")
44 reader := bufio.NewReader(os.Stdin)
45 response, _ := reader.ReadString('\n')
46 response = strings.TrimSpace(response)
47 if response != "y" && response != "Y" {
48 fmt.Println("Aborted.")
49 return nil
50 }
51 }
52
53 fmt.Printf("Connecting to %s...\n", host)
54
55 client, err := ssh.Connect(host)
56 if err != nil {
57 return fmt.Errorf("error connecting to VPS: %w", err)
58 }
59 defer client.Close()
60
61 fmt.Println("\n-> Running apt update...")
62 if err := client.RunSudoStream("apt update"); err != nil {
63 return fmt.Errorf("error running apt update: %w", err)
64 }
65
66 fmt.Println("\n-> Running apt upgrade...")
67 if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil {
68 return fmt.Errorf("error running apt upgrade: %w", err)
69 }
70
71 fmt.Println()
72 if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil {
73 if output == "yes\n" {
74 fmt.Println("Note: A reboot is required to complete the update.")
75 }
76 }
77
78 fmt.Println("Update complete")
79 return nil
80}
diff --git a/cmd/deploy/init.go b/cmd/deploy/init.go
deleted file mode 100644
index 1713879..0000000
--- a/cmd/deploy/init.go
+++ /dev/null
@@ -1,154 +0,0 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/deploy/internal/ssh"
10 "github.com/bdw/deploy/internal/state"
11)
12
13func runInit(args []string) {
14 fs := flag.NewFlagSet("init", flag.ExitOnError)
15 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
16 fs.Parse(args)
17
18 // Load state
19 st, err := state.Load()
20 if err != nil {
21 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
22 os.Exit(1)
23 }
24
25 // Get host from flag or state default
26 if *host == "" {
27 *host = st.GetDefaultHost()
28 }
29
30 if *host == "" {
31 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
32 fs.Usage()
33 os.Exit(1)
34 }
35
36 fmt.Printf("Initializing VPS: %s\n", *host)
37
38 // Connect to VPS
39 client, err := ssh.Connect(*host)
40 if err != nil {
41 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
42 os.Exit(1)
43 }
44 defer client.Close()
45
46 // Detect OS
47 fmt.Println("→ Detecting OS...")
48 osRelease, err := client.Run("cat /etc/os-release")
49 if err != nil {
50 fmt.Fprintf(os.Stderr, "Error detecting OS: %v\n", err)
51 os.Exit(1)
52 }
53
54 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
55 fmt.Fprintf(os.Stderr, "Error: Unsupported OS (only Ubuntu and Debian are supported)\n")
56 os.Exit(1)
57 }
58 fmt.Println(" ✓ Detected Ubuntu/Debian")
59
60 // Check if Caddy is already installed
61 fmt.Println("→ Checking for Caddy...")
62 _, err = client.Run("which caddy")
63 if err == nil {
64 fmt.Println(" ✓ Caddy already installed")
65 } else {
66 // Install Caddy
67 fmt.Println(" Installing Caddy...")
68 installCaddy(client)
69 fmt.Println(" ✓ Caddy installed")
70 }
71
72 // Create Caddyfile
73 fmt.Println("→ Configuring Caddy...")
74 caddyfile := `{
75 email admin@example.com
76}
77
78import /etc/caddy/sites-enabled/*
79`
80 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
81 fmt.Fprintf(os.Stderr, "Error creating Caddyfile: %v\n", err)
82 os.Exit(1)
83 }
84 fmt.Println(" ✓ Caddyfile created")
85
86 // Create directories
87 fmt.Println("→ Creating directories...")
88 if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil {
89 fmt.Fprintf(os.Stderr, "Error creating /etc/deploy/env: %v\n", err)
90 os.Exit(1)
91 }
92 if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil {
93 fmt.Fprintf(os.Stderr, "Error creating /etc/caddy/sites-enabled: %v\n", err)
94 os.Exit(1)
95 }
96 fmt.Println(" ✓ Directories created")
97
98 // Enable and start Caddy
99 fmt.Println("→ Starting Caddy...")
100 if _, err := client.RunSudo("systemctl enable caddy"); err != nil {
101 fmt.Fprintf(os.Stderr, "Error enabling Caddy: %v\n", err)
102 os.Exit(1)
103 }
104 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
105 fmt.Fprintf(os.Stderr, "Error starting Caddy: %v\n", err)
106 os.Exit(1)
107 }
108 fmt.Println(" ✓ Caddy started")
109
110 // Verify Caddy is running
111 fmt.Println("→ Verifying installation...")
112 output, err := client.RunSudo("systemctl is-active caddy")
113 if err != nil || strings.TrimSpace(output) != "active" {
114 fmt.Fprintf(os.Stderr, "Warning: Caddy may not be running properly\n")
115 } else {
116 fmt.Println(" ✓ Caddy is active")
117 }
118
119 // Update state
120 st.GetHost(*host) // Ensure host exists in state
121 if st.GetDefaultHost() == "" {
122 st.SetDefaultHost(*host)
123 fmt.Printf(" Set %s as default host\n", *host)
124 }
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
index ce1605b..ab19a12 100644
--- a/cmd/deploy/list.go
+++ b/cmd/deploy/list.go
@@ -1,44 +1,41 @@
1package main 1package main
2 2
3import ( 3import (
4 "flag"
5 "fmt" 4 "fmt"
6 "os" 5 "os"
7 "text/tabwriter" 6 "text/tabwriter"
8 7
9 "github.com/bdw/deploy/internal/state" 8 "github.com/bdw/deploy/internal/state"
9 "github.com/spf13/cobra"
10) 10)
11 11
12func runList(args []string) { 12var listCmd = &cobra.Command{
13 fs := flag.NewFlagSet("list", flag.ExitOnError) 13 Use: "list",
14 host := fs.String("host", "", "VPS host (SSH config alias or user@host)") 14 Short: "List all deployed apps and sites",
15 fs.Parse(args) 15 RunE: runList,
16}
16 17
17 // Load state 18func runList(cmd *cobra.Command, args []string) error {
18 st, err := state.Load() 19 st, err := state.Load()
19 if err != nil { 20 if err != nil {
20 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) 21 return fmt.Errorf("error loading state: %w", err)
21 os.Exit(1)
22 } 22 }
23 23
24 // Get host from flag or state default 24 host := hostFlag
25 if *host == "" { 25 if host == "" {
26 *host = st.GetDefaultHost() 26 host = st.GetDefaultHost()
27 } 27 }
28 28
29 if *host == "" { 29 if host == "" {
30 fmt.Fprintf(os.Stderr, "Error: --host is required\n") 30 return fmt.Errorf("--host is required")
31 fs.Usage()
32 os.Exit(1)
33 } 31 }
34 32
35 apps := st.ListApps(*host) 33 apps := st.ListApps(host)
36 if len(apps) == 0 { 34 if len(apps) == 0 {
37 fmt.Printf("No deployments found for %s\n", *host) 35 fmt.Printf("No deployments found for %s\n", host)
38 return 36 return nil
39 } 37 }
40 38
41 // Print table
42 w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 39 w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
43 fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") 40 fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT")
44 for name, app := range apps { 41 for name, app := range apps {
@@ -49,4 +46,5 @@ func runList(args []string) {
49 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) 46 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port)
50 } 47 }
51 w.Flush() 48 w.Flush()
49 return nil
52} 50}
diff --git a/cmd/deploy/logs.go b/cmd/deploy/logs.go
new file mode 100644
index 0000000..2b016b8
--- /dev/null
+++ b/cmd/deploy/logs.go
@@ -0,0 +1,75 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/deploy/internal/ssh"
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var logsCmd = &cobra.Command{
12 Use: "logs <app>",
13 Short: "View logs for a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runLogs,
16}
17
18func init() {
19 logsCmd.Flags().BoolP("follow", "f", false, "Follow logs")
20 logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
21}
22
23func runLogs(cmd *cobra.Command, args []string) error {
24 name := args[0]
25 follow, _ := cmd.Flags().GetBool("follow")
26 lines, _ := cmd.Flags().GetInt("lines")
27
28 st, err := state.Load()
29 if err != nil {
30 return fmt.Errorf("error loading state: %w", err)
31 }
32
33 host := hostFlag
34 if host == "" {
35 host = st.GetDefaultHost()
36 }
37
38 if host == "" {
39 return fmt.Errorf("--host is required")
40 }
41
42 app, err := st.GetApp(host, name)
43 if err != nil {
44 return err
45 }
46
47 if app.Type != "app" {
48 return fmt.Errorf("logs are only available for apps, not static sites")
49 }
50
51 client, err := ssh.Connect(host)
52 if err != nil {
53 return fmt.Errorf("error connecting to VPS: %w", err)
54 }
55 defer client.Close()
56
57 journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines)
58 if follow {
59 journalCmd += " -f"
60 }
61
62 if follow {
63 if err := client.RunStream(journalCmd); err != nil {
64 return fmt.Errorf("error fetching logs: %w", err)
65 }
66 } else {
67 output, err := client.Run(journalCmd)
68 if err != nil {
69 return fmt.Errorf("error fetching logs: %w", err)
70 }
71 fmt.Print(output)
72 }
73
74 return nil
75}
diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go
index 51439dd..ad61523 100644
--- a/cmd/deploy/main.go
+++ b/cmd/deploy/main.go
@@ -1,93 +1,65 @@
1package main 1package main
2 2
3import ( 3import (
4 "fmt"
5 "os" 4 "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 "list":
20 runList(os.Args[2:])
21 case "rm", "remove":
22 runRemove(os.Args[2:])
23 case "logs":
24 runLogs(os.Args[2:])
25 case "status":
26 runStatus(os.Args[2:])
27 case "restart":
28 runRestart(os.Args[2:])
29 case "env":
30 runEnv(os.Args[2:])
31 case "webui":
32 runWebUI(os.Args[2:])
33 case "vps":
34 runVPS(os.Args[2:])
35 case "vps-update":
36 runUpdate(os.Args[2:])
37 case "vps-ssh":
38 runSSH(os.Args[2:])
39 case "help", "--help", "-h":
40 printUsage()
41 default:
42 // Default action is deploy - pass all args including the first one
43 runDeploy(os.Args[1:])
44 }
45}
46 5
47func printUsage() { 6 "github.com/bdw/deploy/cmd/deploy/env"
48 usage := `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS 7 "github.com/bdw/deploy/cmd/deploy/host"
49 8 "github.com/spf13/cobra"
50USAGE: 9)
51 deploy [flags] Deploy an app or static site
52 deploy <command> [flags] Run a subcommand
53
54COMMANDS:
55 init Initialize a fresh VPS (one-time setup)
56 list List all deployed apps and sites
57 rm Remove a deployment
58 logs View logs for a deployment
59 status Check status of a deployment
60 restart Restart a deployment
61 env Manage environment variables
62 vps Show VPS health (uptime, disk, memory, load)
63 vps-update Update VPS packages (apt update && upgrade)
64 vps-ssh Open an interactive SSH session
65 webui Launch web UI to manage deployments
66 10
67FLAGS: 11var (
68 Run 'deploy -h' or 'deploy <command> -h' for flags 12 // Persistent flags
13 hostFlag string
69 14
70EXAMPLES: 15 // Version info (set via ldflags)
71 # Initialize VPS (sets it as default host) 16 version = "dev"
72 deploy init --host user@vps-ip 17 commit = "none"
18 date = "unknown"
19)
73 20
74 # Deploy Go app 21var rootCmd = &cobra.Command{
75 deploy --binary ./myapp --domain api.example.com 22 Use: "deploy",
23 Short: "Deploy Go apps and static sites to a VPS with automatic HTTPS",
24 Long: `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS
76 25
77 # Deploy static site 26A CLI tool for deploying applications and static sites to a VPS.
78 deploy --static --dir ./dist --domain example.com 27Uses Caddy for automatic HTTPS and systemd for service management.`,
28 RunE: runDeploy,
29 SilenceUsage: true,
30 SilenceErrors: true,
31}
79 32
80 # List deployments 33func init() {
81 deploy list 34 // Persistent flags available to all subcommands
35 rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)")
82 36
83 # View logs 37 // Root command (deploy) flags
84 deploy logs myapp 38 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)")
39 rootCmd.Flags().Bool("static", false, "Deploy as static site")
40 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)")
41 rootCmd.Flags().String("domain", "", "Domain name (required)")
42 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)")
43 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)")
44 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)")
45 rootCmd.Flags().String("env-file", "", "Path to .env file")
46 rootCmd.Flags().String("args", "", "Arguments to pass to binary")
47 rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)")
85 48
86 # Check VPS health 49 // Add subcommands
87 deploy vps 50 rootCmd.AddCommand(listCmd)
51 rootCmd.AddCommand(logsCmd)
52 rootCmd.AddCommand(statusCmd)
53 rootCmd.AddCommand(restartCmd)
54 rootCmd.AddCommand(removeCmd)
55 rootCmd.AddCommand(env.Cmd)
56 rootCmd.AddCommand(host.Cmd)
57 rootCmd.AddCommand(uiCmd)
58 rootCmd.AddCommand(versionCmd)
59}
88 60
89 # Update VPS packages 61func main() {
90 deploy vps-update 62 if err := rootCmd.Execute(); err != nil {
91` 63 os.Exit(1)
92 fmt.Fprint(os.Stderr, usage) 64 }
93} 65}
diff --git a/cmd/deploy/manage.go b/cmd/deploy/manage.go
deleted file mode 100644
index 1f52b92..0000000
--- a/cmd/deploy/manage.go
+++ /dev/null
@@ -1,306 +0,0 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7
8 "github.com/bdw/deploy/internal/ssh"
9 "github.com/bdw/deploy/internal/state"
10)
11
12func runRemove(args []string) {
13 fs := flag.NewFlagSet("remove", flag.ExitOnError)
14 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
15 fs.Parse(args)
16
17 if len(fs.Args()) == 0 {
18 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
19 fmt.Fprintf(os.Stderr, "Usage: deploy remove <app-name> --host user@vps-ip\n")
20 os.Exit(1)
21 }
22
23 name := fs.Args()[0]
24
25 // Load state
26 st, err := state.Load()
27 if err != nil {
28 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
29 os.Exit(1)
30 }
31
32 // Get host from flag or state default
33 if *host == "" {
34 *host = st.GetDefaultHost()
35 }
36
37 if *host == "" {
38 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
39 fs.Usage()
40 os.Exit(1)
41 }
42
43 // Get app info
44 app, err := st.GetApp(*host, name)
45 if err != nil {
46 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
47 os.Exit(1)
48 }
49
50 fmt.Printf("Removing deployment: %s\n", name)
51
52 // Connect to VPS
53 client, err := ssh.Connect(*host)
54 if err != nil {
55 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
56 os.Exit(1)
57 }
58 defer client.Close()
59
60 if app.Type == "app" {
61 // Stop and disable service
62 fmt.Println("→ Stopping service...")
63 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
64 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
65
66 // Remove systemd unit
67 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
68 client.RunSudo("systemctl daemon-reload")
69
70 // Remove binary
71 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
72
73 // Remove working directory
74 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
75
76 // Remove env file
77 client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name))
78
79 // Remove user
80 client.RunSudo(fmt.Sprintf("userdel %s", name))
81 } else {
82 // Remove static site files
83 fmt.Println("→ Removing files...")
84 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
85 }
86
87 // Remove Caddy config
88 fmt.Println("→ Removing Caddy config...")
89 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
90
91 // Reload Caddy
92 fmt.Println("→ Reloading Caddy...")
93 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
94 fmt.Fprintf(os.Stderr, "Warning: Error reloading Caddy: %v\n", err)
95 }
96
97 // Update state
98 if err := st.RemoveApp(*host, name); err != nil {
99 fmt.Fprintf(os.Stderr, "Error updating state: %v\n", err)
100 os.Exit(1)
101 }
102 if err := st.Save(); err != nil {
103 fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err)
104 os.Exit(1)
105 }
106
107 fmt.Printf("✓ Deployment removed successfully\n")
108}
109
110func runLogs(args []string) {
111 fs := flag.NewFlagSet("logs", flag.ExitOnError)
112 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
113 follow := fs.Bool("f", false, "Follow logs")
114 lines := fs.Int("n", 50, "Number of lines to show")
115 fs.Parse(args)
116
117 if len(fs.Args()) == 0 {
118 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
119 fmt.Fprintf(os.Stderr, "Usage: deploy logs <app-name> --host user@vps-ip\n")
120 os.Exit(1)
121 }
122
123 name := fs.Args()[0]
124
125 // Load state
126 st, err := state.Load()
127 if err != nil {
128 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
129 os.Exit(1)
130 }
131
132 // Get host from flag or state default
133 if *host == "" {
134 *host = st.GetDefaultHost()
135 }
136
137 if *host == "" {
138 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
139 fs.Usage()
140 os.Exit(1)
141 }
142
143 app, err := st.GetApp(*host, name)
144 if err != nil {
145 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
146 os.Exit(1)
147 }
148
149 if app.Type != "app" {
150 fmt.Fprintf(os.Stderr, "Error: logs are only available for apps, not static sites\n")
151 os.Exit(1)
152 }
153
154 // Connect to VPS
155 client, err := ssh.Connect(*host)
156 if err != nil {
157 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
158 os.Exit(1)
159 }
160 defer client.Close()
161
162 // Build journalctl command
163 cmd := fmt.Sprintf("journalctl -u %s -n %d", name, *lines)
164 if *follow {
165 cmd += " -f"
166 }
167
168 // Run command
169 if *follow {
170 // Stream output for follow mode (no sudo needed for journalctl)
171 if err := client.RunStream(cmd); err != nil {
172 fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err)
173 os.Exit(1)
174 }
175 } else {
176 // Buffer output for non-follow mode (no sudo needed for journalctl)
177 output, err := client.Run(cmd)
178 if err != nil {
179 fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err)
180 os.Exit(1)
181 }
182 fmt.Print(output)
183 }
184}
185
186func runStatus(args []string) {
187 fs := flag.NewFlagSet("status", flag.ExitOnError)
188 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
189 fs.Parse(args)
190
191 if len(fs.Args()) == 0 {
192 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
193 fmt.Fprintf(os.Stderr, "Usage: deploy status <app-name> --host user@vps-ip\n")
194 os.Exit(1)
195 }
196
197 name := fs.Args()[0]
198
199 // Load state
200 st, err := state.Load()
201 if err != nil {
202 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
203 os.Exit(1)
204 }
205
206 // Get host from flag or state default
207 if *host == "" {
208 *host = st.GetDefaultHost()
209 }
210
211 if *host == "" {
212 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
213 fs.Usage()
214 os.Exit(1)
215 }
216
217 app, err := st.GetApp(*host, name)
218 if err != nil {
219 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
220 os.Exit(1)
221 }
222
223 if app.Type != "app" {
224 fmt.Fprintf(os.Stderr, "Error: status is only available for apps, not static sites\n")
225 os.Exit(1)
226 }
227
228 // Connect to VPS
229 client, err := ssh.Connect(*host)
230 if err != nil {
231 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
232 os.Exit(1)
233 }
234 defer client.Close()
235
236 // Get status
237 output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name))
238 if err != nil {
239 // systemctl status returns non-zero for non-active services
240 // but we still want to show the output
241 fmt.Print(output)
242 return
243 }
244
245 fmt.Print(output)
246}
247
248func runRestart(args []string) {
249 fs := flag.NewFlagSet("restart", flag.ExitOnError)
250 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
251 fs.Parse(args)
252
253 if len(fs.Args()) == 0 {
254 fmt.Fprintf(os.Stderr, "Error: app name is required\n")
255 fmt.Fprintf(os.Stderr, "Usage: deploy restart <app-name> --host user@vps-ip\n")
256 os.Exit(1)
257 }
258
259 name := fs.Args()[0]
260
261 // Load state
262 st, err := state.Load()
263 if err != nil {
264 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
265 os.Exit(1)
266 }
267
268 // Get host from flag or state default
269 if *host == "" {
270 *host = st.GetDefaultHost()
271 }
272
273 if *host == "" {
274 fmt.Fprintf(os.Stderr, "Error: --host is required\n")
275 fs.Usage()
276 os.Exit(1)
277 }
278
279 app, err := st.GetApp(*host, name)
280 if err != nil {
281 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
282 os.Exit(1)
283 }
284
285 if app.Type != "app" {
286 fmt.Fprintf(os.Stderr, "Error: restart is only available for apps, not static sites\n")
287 os.Exit(1)
288 }
289
290 // Connect to VPS
291 client, err := ssh.Connect(*host)
292 if err != nil {
293 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
294 os.Exit(1)
295 }
296 defer client.Close()
297
298 // Restart service
299 fmt.Printf("Restarting %s...\n", name)
300 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
301 fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err)
302 os.Exit(1)
303 }
304
305 fmt.Println("✓ Service restarted successfully")
306}
diff --git a/cmd/deploy/remove.go b/cmd/deploy/remove.go
new file mode 100644
index 0000000..5a98bf3
--- /dev/null
+++ b/cmd/deploy/remove.go
@@ -0,0 +1,83 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/deploy/internal/ssh"
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var removeCmd = &cobra.Command{
12 Use: "remove <app>",
13 Aliases: []string{"rm"},
14 Short: "Remove a deployment",
15 Args: cobra.ExactArgs(1),
16 RunE: runRemove,
17}
18
19func runRemove(cmd *cobra.Command, args []string) error {
20 name := args[0]
21
22 st, err := state.Load()
23 if err != nil {
24 return fmt.Errorf("error loading state: %w", err)
25 }
26
27 host := hostFlag
28 if host == "" {
29 host = st.GetDefaultHost()
30 }
31
32 if host == "" {
33 return fmt.Errorf("--host is required")
34 }
35
36 app, err := st.GetApp(host, name)
37 if err != nil {
38 return err
39 }
40
41 fmt.Printf("Removing deployment: %s\n", name)
42
43 client, err := ssh.Connect(host)
44 if err != nil {
45 return fmt.Errorf("error connecting to VPS: %w", err)
46 }
47 defer client.Close()
48
49 if app.Type == "app" {
50 fmt.Println("-> Stopping service...")
51 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
52 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
53
54 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
55 client.RunSudo("systemctl daemon-reload")
56
57 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
58 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
59 client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name))
60 client.RunSudo(fmt.Sprintf("userdel %s", name))
61 } else {
62 fmt.Println("-> Removing files...")
63 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
64 }
65
66 fmt.Println("-> Removing Caddy config...")
67 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
68
69 fmt.Println("-> Reloading Caddy...")
70 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
71 fmt.Printf("Warning: Error reloading Caddy: %v\n", err)
72 }
73
74 if err := st.RemoveApp(host, name); err != nil {
75 return fmt.Errorf("error updating state: %w", err)
76 }
77 if err := st.Save(); err != nil {
78 return fmt.Errorf("error saving state: %w", err)
79 }
80
81 fmt.Println("Deployment removed successfully")
82 return nil
83}
diff --git a/cmd/deploy/restart.go b/cmd/deploy/restart.go
new file mode 100644
index 0000000..d1cfa86
--- /dev/null
+++ b/cmd/deploy/restart.go
@@ -0,0 +1,57 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/deploy/internal/ssh"
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var restartCmd = &cobra.Command{
12 Use: "restart <app>",
13 Short: "Restart a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runRestart,
16}
17
18func runRestart(cmd *cobra.Command, args []string) error {
19 name := args[0]
20
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host := hostFlag
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30
31 if host == "" {
32 return fmt.Errorf("--host is required")
33 }
34
35 app, err := st.GetApp(host, name)
36 if err != nil {
37 return err
38 }
39
40 if app.Type != "app" {
41 return fmt.Errorf("restart is only available for apps, not static sites")
42 }
43
44 client, err := ssh.Connect(host)
45 if err != nil {
46 return fmt.Errorf("error connecting to VPS: %w", err)
47 }
48 defer client.Close()
49
50 fmt.Printf("Restarting %s...\n", name)
51 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
52 return fmt.Errorf("error restarting service: %w", err)
53 }
54
55 fmt.Println("Service restarted successfully")
56 return nil
57}
diff --git a/cmd/deploy/root.go b/cmd/deploy/root.go
new file mode 100644
index 0000000..adbc7c8
--- /dev/null
+++ b/cmd/deploy/root.go
@@ -0,0 +1,377 @@
1package main
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10
11 "github.com/bdw/deploy/internal/ssh"
12 "github.com/bdw/deploy/internal/state"
13 "github.com/bdw/deploy/internal/templates"
14 "github.com/spf13/cobra"
15)
16
17func runDeploy(cmd *cobra.Command, args []string) error {
18 flags := cmd.Flags()
19
20 binary, _ := flags.GetString("binary")
21 static, _ := flags.GetBool("static")
22 dir, _ := flags.GetString("dir")
23 domain, _ := flags.GetString("domain")
24 name, _ := flags.GetString("name")
25 port, _ := flags.GetInt("port")
26 envVars, _ := flags.GetStringArray("env")
27 envFile, _ := flags.GetString("env-file")
28 binaryArgs, _ := flags.GetString("args")
29 files, _ := flags.GetStringArray("file")
30
31 // Get host from flag or state default
32 host := hostFlag
33 if host == "" {
34 st, err := state.Load()
35 if err != nil {
36 return fmt.Errorf("error loading state: %w", err)
37 }
38 host = st.GetDefaultHost()
39 }
40
41 // If no flags provided, show help
42 if domain == "" && binary == "" && !static {
43 return cmd.Help()
44 }
45
46 if host == "" || domain == "" {
47 return fmt.Errorf("--host and --domain are required")
48 }
49
50 if static {
51 return deployStatic(host, domain, name, dir)
52 }
53 return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files)
54}
55
56func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error {
57 if binaryPath == "" {
58 return fmt.Errorf("--binary is required")
59 }
60
61 if name == "" {
62 name = filepath.Base(binaryPath)
63 }
64
65 if _, err := os.Stat(binaryPath); err != nil {
66 return fmt.Errorf("binary not found: %s", binaryPath)
67 }
68
69 fmt.Printf("Deploying app: %s\n", name)
70 fmt.Printf(" Domain: %s\n", domain)
71 fmt.Printf(" Binary: %s\n", binaryPath)
72
73 st, err := state.Load()
74 if err != nil {
75 return fmt.Errorf("error loading state: %w", err)
76 }
77
78 existingApp, _ := st.GetApp(host, name)
79 var port int
80 if existingApp != nil {
81 port = existingApp.Port
82 fmt.Printf(" Updating existing deployment (port %d)\n", port)
83 } else {
84 if portOverride > 0 {
85 port = portOverride
86 } else {
87 port = st.AllocatePort(host)
88 }
89 fmt.Printf(" Allocated port: %d\n", port)
90 }
91
92 env := make(map[string]string)
93 if existingApp != nil {
94 for k, v := range existingApp.Env {
95 env[k] = v
96 }
97 if args == "" && existingApp.Args != "" {
98 args = existingApp.Args
99 }
100 if len(files) == 0 && len(existingApp.Files) > 0 {
101 files = existingApp.Files
102 }
103 }
104
105 for _, e := range envVars {
106 parts := strings.SplitN(e, "=", 2)
107 if len(parts) == 2 {
108 env[parts[0]] = parts[1]
109 }
110 }
111
112 if envFile != "" {
113 fileEnv, err := parseEnvFile(envFile)
114 if err != nil {
115 return fmt.Errorf("error reading env file: %w", err)
116 }
117 for k, v := range fileEnv {
118 env[k] = v
119 }
120 }
121
122 env["PORT"] = strconv.Itoa(port)
123
124 client, err := ssh.Connect(host)
125 if err != nil {
126 return fmt.Errorf("error connecting to VPS: %w", err)
127 }
128 defer client.Close()
129
130 fmt.Println("-> Uploading binary...")
131 remoteTmpPath := fmt.Sprintf("/tmp/%s", name)
132 if err := client.Upload(binaryPath, remoteTmpPath); err != nil {
133 return fmt.Errorf("error uploading binary: %w", err)
134 }
135
136 fmt.Println("-> Creating system user...")
137 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name))
138
139 fmt.Println("-> Setting up directories...")
140 workDir := fmt.Sprintf("/var/lib/%s", name)
141 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
142 return fmt.Errorf("error creating work directory: %w", err)
143 }
144 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil {
145 return fmt.Errorf("error setting work directory ownership: %w", err)
146 }
147
148 fmt.Println("-> Installing binary...")
149 binaryDest := fmt.Sprintf("/usr/local/bin/%s", name)
150 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
151 return fmt.Errorf("error moving binary: %w", err)
152 }
153 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
154 return fmt.Errorf("error making binary executable: %w", err)
155 }
156
157 if len(files) > 0 {
158 fmt.Println("-> Uploading config files...")
159 for _, file := range files {
160 if _, err := os.Stat(file); err != nil {
161 return fmt.Errorf("config file not found: %s", file)
162 }
163
164 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
165 remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file))
166
167 if err := client.Upload(file, remoteTmpPath); err != nil {
168 return fmt.Errorf("error uploading config file %s: %w", file, err)
169 }
170
171 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil {
172 return fmt.Errorf("error moving config file %s: %w", file, err)
173 }
174
175 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil {
176 return fmt.Errorf("error setting config file ownership %s: %w", file, err)
177 }
178
179 fmt.Printf(" Uploaded: %s\n", file)
180 }
181 }
182
183 fmt.Println("-> Creating environment file...")
184 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name)
185 envContent := ""
186 for k, v := range env {
187 envContent += fmt.Sprintf("%s=%s\n", k, v)
188 }
189 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
190 return fmt.Errorf("error creating env file: %w", err)
191 }
192 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
193 return fmt.Errorf("error setting env file permissions: %w", err)
194 }
195 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil {
196 return fmt.Errorf("error setting env file ownership: %w", err)
197 }
198
199 fmt.Println("-> Creating systemd service...")
200 serviceContent, err := templates.SystemdService(map[string]string{
201 "Name": name,
202 "User": name,
203 "WorkDir": workDir,
204 "BinaryPath": binaryDest,
205 "Port": strconv.Itoa(port),
206 "EnvFile": envFilePath,
207 "Args": args,
208 })
209 if err != nil {
210 return fmt.Errorf("error generating systemd unit: %w", err)
211 }
212
213 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
214 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
215 return fmt.Errorf("error creating systemd unit: %w", err)
216 }
217
218 fmt.Println("-> Configuring Caddy...")
219 caddyContent, err := templates.AppCaddy(map[string]string{
220 "Domain": domain,
221 "Port": strconv.Itoa(port),
222 })
223 if err != nil {
224 return fmt.Errorf("error generating Caddy config: %w", err)
225 }
226
227 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
228 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
229 return fmt.Errorf("error creating Caddy config: %w", err)
230 }
231
232 fmt.Println("-> Reloading systemd...")
233 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
234 return fmt.Errorf("error reloading systemd: %w", err)
235 }
236
237 fmt.Println("-> Starting service...")
238 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil {
239 return fmt.Errorf("error enabling service: %w", err)
240 }
241 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
242 return fmt.Errorf("error starting service: %w", err)
243 }
244
245 fmt.Println("-> Reloading Caddy...")
246 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
247 return fmt.Errorf("error reloading Caddy: %w", err)
248 }
249
250 st.AddApp(host, name, &state.App{
251 Type: "app",
252 Domain: domain,
253 Port: port,
254 Env: env,
255 Args: args,
256 Files: files,
257 })
258 if err := st.Save(); err != nil {
259 return fmt.Errorf("error saving state: %w", err)
260 }
261
262 fmt.Printf("\n App deployed successfully!\n")
263 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
264 return nil
265}
266
267func deployStatic(host, domain, name, dir string) error {
268 if name == "" {
269 name = domain
270 }
271
272 if _, err := os.Stat(dir); err != nil {
273 return fmt.Errorf("directory not found: %s", dir)
274 }
275
276 fmt.Printf("Deploying static site: %s\n", name)
277 fmt.Printf(" Domain: %s\n", domain)
278 fmt.Printf(" Directory: %s\n", dir)
279
280 st, err := state.Load()
281 if err != nil {
282 return fmt.Errorf("error loading state: %w", err)
283 }
284
285 client, err := ssh.Connect(host)
286 if err != nil {
287 return fmt.Errorf("error connecting to VPS: %w", err)
288 }
289 defer client.Close()
290
291 remoteDir := fmt.Sprintf("/var/www/%s", name)
292 fmt.Println("-> Creating remote directory...")
293 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
294 return fmt.Errorf("error creating remote directory: %w", err)
295 }
296
297 currentUser, err := client.Run("whoami")
298 if err != nil {
299 return fmt.Errorf("error getting current user: %w", err)
300 }
301 currentUser = strings.TrimSpace(currentUser)
302
303 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
304 return fmt.Errorf("error setting temporary ownership: %w", err)
305 }
306
307 fmt.Println("-> Uploading files...")
308 if err := client.UploadDir(dir, remoteDir); err != nil {
309 return fmt.Errorf("error uploading files: %w", err)
310 }
311
312 fmt.Println("-> Setting permissions...")
313 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
314 return fmt.Errorf("error setting ownership: %w", err)
315 }
316 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
317 return fmt.Errorf("error setting directory permissions: %w", err)
318 }
319 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
320 return fmt.Errorf("error setting file permissions: %w", err)
321 }
322
323 fmt.Println("-> Configuring Caddy...")
324 caddyContent, err := templates.StaticCaddy(map[string]string{
325 "Domain": domain,
326 "RootDir": remoteDir,
327 })
328 if err != nil {
329 return fmt.Errorf("error generating Caddy config: %w", err)
330 }
331
332 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
333 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
334 return fmt.Errorf("error creating Caddy config: %w", err)
335 }
336
337 fmt.Println("-> Reloading Caddy...")
338 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
339 return fmt.Errorf("error reloading Caddy: %w", err)
340 }
341
342 st.AddApp(host, name, &state.App{
343 Type: "static",
344 Domain: domain,
345 })
346 if err := st.Save(); err != nil {
347 return fmt.Errorf("error saving state: %w", err)
348 }
349
350 fmt.Printf("\n Static site deployed successfully!\n")
351 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
352 return nil
353}
354
355func parseEnvFile(path string) (map[string]string, error) {
356 file, err := os.Open(path)
357 if err != nil {
358 return nil, err
359 }
360 defer file.Close()
361
362 env := make(map[string]string)
363 scanner := bufio.NewScanner(file)
364 for scanner.Scan() {
365 line := strings.TrimSpace(scanner.Text())
366 if line == "" || strings.HasPrefix(line, "#") {
367 continue
368 }
369
370 parts := strings.SplitN(line, "=", 2)
371 if len(parts) == 2 {
372 env[parts[0]] = parts[1]
373 }
374 }
375
376 return env, scanner.Err()
377}
diff --git a/cmd/deploy/status.go b/cmd/deploy/status.go
new file mode 100644
index 0000000..4bcfc68
--- /dev/null
+++ b/cmd/deploy/status.go
@@ -0,0 +1,60 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/deploy/internal/ssh"
7 "github.com/bdw/deploy/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var statusCmd = &cobra.Command{
12 Use: "status <app>",
13 Short: "Check status of a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runStatus,
16}
17
18func runStatus(cmd *cobra.Command, args []string) error {
19 name := args[0]
20
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host := hostFlag
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30
31 if host == "" {
32 return fmt.Errorf("--host is required")
33 }
34
35 app, err := st.GetApp(host, name)
36 if err != nil {
37 return err
38 }
39
40 if app.Type != "app" {
41 return fmt.Errorf("status is only available for apps, not static sites")
42 }
43
44 client, err := ssh.Connect(host)
45 if err != nil {
46 return fmt.Errorf("error connecting to VPS: %w", err)
47 }
48 defer client.Close()
49
50 output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name))
51 if err != nil {
52 // systemctl status returns non-zero for non-active services
53 // but we still want to show the output
54 fmt.Print(output)
55 return nil
56 }
57
58 fmt.Print(output)
59 return nil
60}
diff --git a/cmd/deploy/webui.go b/cmd/deploy/ui.go
index f57400e..2ca88e0 100644
--- a/cmd/deploy/webui.go
+++ b/cmd/deploy/ui.go
@@ -3,65 +3,45 @@ package main
3import ( 3import (
4 "embed" 4 "embed"
5 "encoding/json" 5 "encoding/json"
6 "flag"
7 "fmt" 6 "fmt"
8 "html/template" 7 "html/template"
9 "net/http" 8 "net/http"
10 "os"
11 "sort" 9 "sort"
12 "strconv" 10 "strconv"
13 11
14 "github.com/bdw/deploy/internal/state" 12 "github.com/bdw/deploy/internal/state"
15 "github.com/bdw/deploy/internal/templates" 13 "github.com/bdw/deploy/internal/templates"
14 "github.com/spf13/cobra"
16) 15)
17 16
18//go:embed templates/*.html 17//go:embed templates/*.html
19var templatesFS embed.FS 18var templatesFS embed.FS
20 19
21func runWebUI(args []string) { 20var uiCmd = &cobra.Command{
22 fs := flag.NewFlagSet("webui", flag.ExitOnError) 21 Use: "ui",
23 port := fs.String("port", "8080", "Port to run the web UI on") 22 Short: "Launch web management UI",
24 help := fs.Bool("h", false, "Show help") 23 RunE: runUI,
25 24}
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 25
38EXAMPLE: 26func init() {
39 # Launch web UI on default port (8080) 27 uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on")
40 deploy webui 28}
41 29
42 # Launch on custom port 30func runUI(cmd *cobra.Command, args []string) error {
43 deploy webui -port 3000 31 port, _ := cmd.Flags().GetString("port")
44`)
45 os.Exit(0)
46 }
47 32
48 // Parse template
49 tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") 33 tmpl, err := template.ParseFS(templatesFS, "templates/webui.html")
50 if err != nil { 34 if err != nil {
51 fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) 35 return fmt.Errorf("error parsing template: %w", err)
52 os.Exit(1)
53 } 36 }
54 37
55 // Handler for the main page
56 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 38 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
57 // Reload state on each request to show latest changes
58 st, err := state.Load() 39 st, err := state.Load()
59 if err != nil { 40 if err != nil {
60 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) 41 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
61 return 42 return
62 } 43 }
63 44
64 // Prepare data for template
65 type AppData struct { 45 type AppData struct {
66 Name string 46 Name string
67 Type string 47 Type string
@@ -90,7 +70,6 @@ EXAMPLE:
90 }) 70 })
91 } 71 }
92 72
93 // Sort apps by name
94 sort.Slice(apps, func(i, j int) bool { 73 sort.Slice(apps, func(i, j int) bool {
95 return apps[i].Name < apps[j].Name 74 return apps[i].Name < apps[j].Name
96 }) 75 })
@@ -101,7 +80,6 @@ EXAMPLE:
101 }) 80 })
102 } 81 }
103 82
104 // Sort hosts by name
105 sort.Slice(hosts, func(i, j int) bool { 83 sort.Slice(hosts, func(i, j int) bool {
106 return hosts[i].Host < hosts[j].Host 84 return hosts[i].Host < hosts[j].Host
107 }) 85 })
@@ -118,7 +96,6 @@ EXAMPLE:
118 } 96 }
119 }) 97 })
120 98
121 // API endpoint to get state as JSON
122 http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { 99 http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) {
123 st, err := state.Load() 100 st, err := state.Load()
124 if err != nil { 101 if err != nil {
@@ -130,7 +107,6 @@ EXAMPLE:
130 json.NewEncoder(w).Encode(st) 107 json.NewEncoder(w).Encode(st)
131 }) 108 })
132 109
133 // API endpoint to get rendered configs for an app
134 http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { 110 http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) {
135 host := r.URL.Query().Get("host") 111 host := r.URL.Query().Get("host")
136 appName := r.URL.Query().Get("app") 112 appName := r.URL.Query().Get("app")
@@ -154,7 +130,6 @@ EXAMPLE:
154 130
155 configs := make(map[string]string) 131 configs := make(map[string]string)
156 132
157 // Render environment file
158 if app.Env != nil && len(app.Env) > 0 { 133 if app.Env != nil && len(app.Env) > 0 {
159 envContent := "" 134 envContent := ""
160 for k, v := range app.Env { 135 for k, v := range app.Env {
@@ -164,9 +139,7 @@ EXAMPLE:
164 configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) 139 configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName)
165 } 140 }
166 141
167 // Render configs based on app type
168 if app.Type == "app" { 142 if app.Type == "app" {
169 // Render systemd service
170 workDir := fmt.Sprintf("/var/lib/%s", appName) 143 workDir := fmt.Sprintf("/var/lib/%s", appName)
171 binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) 144 binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName)
172 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) 145 envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName)
@@ -187,7 +160,6 @@ EXAMPLE:
187 configs["systemd"] = serviceContent 160 configs["systemd"] = serviceContent
188 configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) 161 configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName)
189 162
190 // Render Caddy config
191 caddyContent, err := templates.AppCaddy(map[string]string{ 163 caddyContent, err := templates.AppCaddy(map[string]string{
192 "Domain": app.Domain, 164 "Domain": app.Domain,
193 "Port": strconv.Itoa(app.Port), 165 "Port": strconv.Itoa(app.Port),
@@ -199,7 +171,6 @@ EXAMPLE:
199 configs["caddy"] = caddyContent 171 configs["caddy"] = caddyContent
200 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) 172 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
201 } else if app.Type == "static" { 173 } else if app.Type == "static" {
202 // Render Caddy config for static site
203 remoteDir := fmt.Sprintf("/var/www/%s", appName) 174 remoteDir := fmt.Sprintf("/var/www/%s", appName)
204 caddyContent, err := templates.StaticCaddy(map[string]string{ 175 caddyContent, err := templates.StaticCaddy(map[string]string{
205 "Domain": app.Domain, 176 "Domain": app.Domain,
@@ -217,12 +188,12 @@ EXAMPLE:
217 json.NewEncoder(w).Encode(configs) 188 json.NewEncoder(w).Encode(configs)
218 }) 189 })
219 190
220 addr := fmt.Sprintf("localhost:%s", *port) 191 addr := fmt.Sprintf("localhost:%s", port)
221 fmt.Printf("Starting web UI on http://%s\n", addr) 192 fmt.Printf("Starting web UI on http://%s\n", addr)
222 fmt.Printf("Press Ctrl+C to stop\n") 193 fmt.Printf("Press Ctrl+C to stop\n")
223 194
224 if err := http.ListenAndServe(addr, nil); err != nil { 195 if err := http.ListenAndServe(addr, nil); err != nil {
225 fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) 196 return fmt.Errorf("error starting server: %w", err)
226 os.Exit(1)
227 } 197 }
198 return nil
228} 199}
diff --git a/cmd/deploy/version.go b/cmd/deploy/version.go
new file mode 100644
index 0000000..d2cd430
--- /dev/null
+++ b/cmd/deploy/version.go
@@ -0,0 +1,17 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/spf13/cobra"
7)
8
9var versionCmd = &cobra.Command{
10 Use: "version",
11 Short: "Show version information",
12 Run: func(cmd *cobra.Command, args []string) {
13 fmt.Printf("deploy version %s\n", version)
14 fmt.Printf(" commit: %s\n", commit)
15 fmt.Printf(" built: %s\n", date)
16 },
17}
diff --git a/cmd/deploy/vps.go b/cmd/deploy/vps.go
deleted file mode 100644
index bd6278b..0000000
--- a/cmd/deploy/vps.go
+++ /dev/null
@@ -1,229 +0,0 @@
1package main
2
3import (
4 "flag"
5 "fmt"
6 "os"
7 "os/exec"
8
9 "github.com/bdw/deploy/internal/ssh"
10 "github.com/bdw/deploy/internal/state"
11)
12
13func runVPS(args []string) {
14 fs := flag.NewFlagSet("vps", flag.ExitOnError)
15 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
16 fs.Parse(args)
17
18 // Load state
19 st, err := state.Load()
20 if err != nil {
21 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
22 os.Exit(1)
23 }
24
25 // Get host from flag or state default
26 if *host == "" {
27 *host = st.GetDefaultHost()
28 }
29
30 if *host == "" {
31 fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n")
32 fs.Usage()
33 os.Exit(1)
34 }
35
36 fmt.Printf("Connecting to %s...\n\n", *host)
37
38 // Connect to VPS
39 client, err := ssh.Connect(*host)
40 if err != nil {
41 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
42 os.Exit(1)
43 }
44 defer client.Close()
45
46 // Uptime
47 fmt.Println("UPTIME")
48 if output, err := client.Run("uptime -p"); err == nil {
49 fmt.Printf(" %s", output)
50 }
51 if output, err := client.Run("uptime -s"); err == nil {
52 fmt.Printf(" Since: %s", output)
53 }
54 fmt.Println()
55
56 // Load average
57 fmt.Println("LOAD")
58 if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil {
59 fmt.Printf(" 1m, 5m, 15m: %s", output)
60 }
61 fmt.Println()
62
63 // Memory
64 fmt.Println("MEMORY")
65 if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil {
66 fmt.Print(output)
67 }
68 if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil {
69 fmt.Print(output)
70 }
71 fmt.Println()
72
73 // Disk
74 fmt.Println("DISK")
75 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil {
76 fmt.Print(output)
77 }
78 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil {
79 fmt.Print(output)
80 }
81 fmt.Println()
82
83 // Updates available
84 fmt.Println("UPDATES")
85 if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil {
86 fmt.Print(output)
87 }
88 fmt.Println()
89
90 // Services
91 fmt.Println("SERVICES")
92 if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil {
93 // Parse the output
94 if output == "active\n" {
95 fmt.Println(" Caddy: active")
96 } else {
97 fmt.Println(" Caddy: inactive")
98 }
99 }
100
101 // Count deployed apps that are running
102 hostState := st.GetHost(*host)
103 if hostState != nil && len(hostState.Apps) > 0 {
104 activeCount := 0
105 for name, app := range hostState.Apps {
106 if app.Type == "app" {
107 if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" {
108 activeCount++
109 }
110 }
111 }
112 appCount := 0
113 for _, app := range hostState.Apps {
114 if app.Type == "app" {
115 appCount++
116 }
117 }
118 fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount)
119 }
120}
121
122func runUpdate(args []string) {
123 fs := flag.NewFlagSet("vps-update", flag.ExitOnError)
124 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
125 yes := fs.Bool("y", false, "Skip confirmation prompt")
126 fs.Parse(args)
127
128 // Load state
129 st, err := state.Load()
130 if err != nil {
131 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
132 os.Exit(1)
133 }
134
135 // Get host from flag or state default
136 if *host == "" {
137 *host = st.GetDefaultHost()
138 }
139
140 if *host == "" {
141 fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n")
142 fs.Usage()
143 os.Exit(1)
144 }
145
146 // Confirm unless -y flag is set
147 if !*yes {
148 fmt.Printf("This will run apt update && apt upgrade on %s\n", *host)
149 fmt.Print("Continue? [y/N]: ")
150 var response string
151 fmt.Scanln(&response)
152 if response != "y" && response != "Y" {
153 fmt.Println("Aborted.")
154 return
155 }
156 }
157
158 fmt.Printf("Connecting to %s...\n", *host)
159
160 // Connect to VPS
161 client, err := ssh.Connect(*host)
162 if err != nil {
163 fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err)
164 os.Exit(1)
165 }
166 defer client.Close()
167
168 // Run apt update
169 fmt.Println("\n→ Running apt update...")
170 if err := client.RunSudoStream("apt update"); err != nil {
171 fmt.Fprintf(os.Stderr, "Error running apt update: %v\n", err)
172 os.Exit(1)
173 }
174
175 // Run apt upgrade
176 fmt.Println("\n→ Running apt upgrade...")
177 if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil {
178 fmt.Fprintf(os.Stderr, "Error running apt upgrade: %v\n", err)
179 os.Exit(1)
180 }
181
182 // Check if reboot is required
183 fmt.Println()
184 if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil {
185 if output == "yes\n" {
186 fmt.Println("Note: A reboot is required to complete the update.")
187 }
188 }
189
190 fmt.Println("✓ Update complete")
191}
192
193func runSSH(args []string) {
194 fs := flag.NewFlagSet("vps-ssh", flag.ExitOnError)
195 host := fs.String("host", "", "VPS host (SSH config alias or user@host)")
196 fs.Parse(args)
197
198 // Load state
199 st, err := state.Load()
200 if err != nil {
201 fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err)
202 os.Exit(1)
203 }
204
205 // Get host from flag or state default
206 if *host == "" {
207 *host = st.GetDefaultHost()
208 }
209
210 if *host == "" {
211 fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n")
212 fs.Usage()
213 os.Exit(1)
214 }
215
216 // Launch interactive SSH session
217 cmd := exec.Command("ssh", *host)
218 cmd.Stdin = os.Stdin
219 cmd.Stdout = os.Stdout
220 cmd.Stderr = os.Stderr
221
222 if err := cmd.Run(); err != nil {
223 if exitErr, ok := err.(*exec.ExitError); ok {
224 os.Exit(exitErr.ExitCode())
225 }
226 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
227 os.Exit(1)
228 }
229}
diff --git a/go.mod b/go.mod
index 0d29deb..b048ba7 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,13 @@ module github.com/bdw/deploy
2 2
3go 1.21 3go 1.21
4 4
5require golang.org/x/crypto v0.31.0 5require (
6 github.com/spf13/cobra v1.10.2
7 golang.org/x/crypto v0.31.0
8)
6 9
7require golang.org/x/sys v0.28.0 // indirect 10require (
11 github.com/inconshreveable/mousetrap v1.1.0 // indirect
12 github.com/spf13/pflag v1.0.9 // indirect
13 golang.org/x/sys v0.28.0 // indirect
14)
diff --git a/go.sum b/go.sum
index 7fa4005..42e3e06 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,16 @@
1github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
6github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
7github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
8github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
1golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 10golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
2golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 11golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
3golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 12golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
4golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 13golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 14golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
6golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 15golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
16gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=