summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/ship/deploy.go598
-rw-r--r--cmd/ship/main.go92
-rw-r--r--cmd/ship/root.go671
3 files changed, 682 insertions, 679 deletions
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go
new file mode 100644
index 0000000..24eed1e
--- /dev/null
+++ b/cmd/ship/deploy.go
@@ -0,0 +1,598 @@
1package main
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10
11 "github.com/bdw/ship/internal/ssh"
12 "github.com/bdw/ship/internal/state"
13 "github.com/bdw/ship/internal/templates"
14 "github.com/spf13/cobra"
15)
16
17// DeployOptions contains all options for deploying or updating an app
18type DeployOptions struct {
19 Host string
20 Domain string
21 Name string
22 Binary string
23 Dir string // for static sites
24 Port int
25 Args string
26 Files []string
27 Memory string
28 CPU string
29 Env map[string]string // merged env vars
30 IsUpdate bool
31}
32
33func runDeploy(cmd *cobra.Command, args []string) error {
34 flags := cmd.Flags()
35
36 // Parse CLI flags
37 binary, _ := flags.GetString("binary")
38 static, _ := flags.GetBool("static")
39 dir, _ := flags.GetString("dir")
40 domain, _ := flags.GetString("domain")
41 name, _ := flags.GetString("name")
42 port, _ := flags.GetInt("port")
43 envVars, _ := flags.GetStringArray("env")
44 envFile, _ := flags.GetString("env-file")
45 argsFlag, _ := flags.GetString("args")
46 files, _ := flags.GetStringArray("file")
47 memory, _ := flags.GetString("memory")
48 cpu, _ := flags.GetString("cpu")
49
50 // Get host from flag or state default
51 host := hostFlag
52 if host == "" {
53 st, err := state.Load()
54 if err != nil {
55 return fmt.Errorf("error loading state: %w", err)
56 }
57 host = st.GetDefaultHost()
58 }
59
60 // If no flags provided, show help
61 if domain == "" && binary == "" && !static && name == "" {
62 return cmd.Help()
63 }
64
65 if host == "" {
66 return fmt.Errorf("--host is required")
67 }
68
69 // Load state once - this will be used throughout
70 st, err := state.Load()
71 if err != nil {
72 return fmt.Errorf("error loading state: %w", err)
73 }
74 hostState := st.GetHost(host)
75
76 // Config update mode: --name provided without --binary or --static
77 if name != "" && binary == "" && !static {
78 existingApp, err := st.GetApp(host, name)
79 if err != nil {
80 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name)
81 }
82
83 // Build merged config starting from existing app
84 opts := DeployOptions{
85 Host: host,
86 Name: name,
87 Port: existingApp.Port,
88 Args: existingApp.Args,
89 Files: existingApp.Files,
90 Memory: existingApp.Memory,
91 CPU: existingApp.CPU,
92 Env: make(map[string]string),
93 }
94 for k, v := range existingApp.Env {
95 opts.Env[k] = v
96 }
97
98 // Override with CLI flags if provided
99 if argsFlag != "" {
100 opts.Args = argsFlag
101 }
102 if len(files) > 0 {
103 opts.Files = files
104 }
105 if memory != "" {
106 opts.Memory = memory
107 }
108 if cpu != "" {
109 opts.CPU = cpu
110 }
111
112 // Merge env vars (CLI overrides existing)
113 for _, e := range envVars {
114 parts := strings.SplitN(e, "=", 2)
115 if len(parts) == 2 {
116 opts.Env[parts[0]] = parts[1]
117 }
118 }
119 if envFile != "" {
120 fileEnv, err := parseEnvFile(envFile)
121 if err != nil {
122 return fmt.Errorf("error reading env file: %w", err)
123 }
124 for k, v := range fileEnv {
125 opts.Env[k] = v
126 }
127 }
128
129 return updateAppConfig(st, opts)
130 }
131
132 // Infer name early so we can use it for subdomain generation and existing app lookup
133 if name == "" {
134 if static {
135 name = domain
136 if name == "" && hostState.BaseDomain != "" {
137 name = filepath.Base(dir)
138 }
139 } else {
140 name = filepath.Base(binary)
141 }
142 }
143
144 // Check if this is an update to an existing app/site
145 existingApp, _ := st.GetApp(host, name)
146 isUpdate := existingApp != nil
147
148 // For new deployments, require domain or base domain
149 if !isUpdate && domain == "" && hostState.BaseDomain == "" {
150 return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')")
151 }
152
153 // Build merged config, starting from existing app if updating
154 opts := DeployOptions{
155 Host: host,
156 Name: name,
157 Binary: binary,
158 Dir: dir,
159 IsUpdate: isUpdate,
160 }
161
162 // Merge domain: auto-subdomain + (user-provided or existing custom domain)
163 var domains []string
164 if hostState.BaseDomain != "" {
165 domains = append(domains, name+"."+hostState.BaseDomain)
166 }
167 if domain != "" {
168 domains = append(domains, domain)
169 } else if isUpdate && existingApp.Domain != "" {
170 for _, d := range strings.Split(existingApp.Domain, ",") {
171 d = strings.TrimSpace(d)
172 if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) {
173 domains = append(domains, d)
174 }
175 }
176 }
177 opts.Domain = strings.Join(domains, ", ")
178
179 // For apps, merge all config fields
180 if !static {
181 // Start with existing values if updating
182 if isUpdate {
183 opts.Port = existingApp.Port
184 opts.Args = existingApp.Args
185 opts.Files = existingApp.Files
186 opts.Memory = existingApp.Memory
187 opts.CPU = existingApp.CPU
188 opts.Env = make(map[string]string)
189 for k, v := range existingApp.Env {
190 opts.Env[k] = v
191 }
192 } else {
193 opts.Port = port
194 opts.Env = make(map[string]string)
195 }
196
197 // Override with CLI flags if provided
198 if argsFlag != "" {
199 opts.Args = argsFlag
200 }
201 if len(files) > 0 {
202 opts.Files = files
203 }
204 if memory != "" {
205 opts.Memory = memory
206 }
207 if cpu != "" {
208 opts.CPU = cpu
209 }
210
211 // Merge env vars (CLI overrides existing)
212 for _, e := range envVars {
213 parts := strings.SplitN(e, "=", 2)
214 if len(parts) == 2 {
215 opts.Env[parts[0]] = parts[1]
216 }
217 }
218 if envFile != "" {
219 fileEnv, err := parseEnvFile(envFile)
220 if err != nil {
221 return fmt.Errorf("error reading env file: %w", err)
222 }
223 for k, v := range fileEnv {
224 opts.Env[k] = v
225 }
226 }
227 }
228
229 if static {
230 return deployStatic(st, opts)
231 }
232 return deployApp(st, opts)
233}
234
235func deployApp(st *state.State, opts DeployOptions) error {
236 if opts.Binary == "" {
237 return fmt.Errorf("--binary is required")
238 }
239
240 if _, err := os.Stat(opts.Binary); err != nil {
241 return fmt.Errorf("binary not found: %s", opts.Binary)
242 }
243
244 fmt.Printf("Deploying app: %s\n", opts.Name)
245 fmt.Printf(" Domain(s): %s\n", opts.Domain)
246 fmt.Printf(" Binary: %s\n", opts.Binary)
247
248 // Allocate port for new apps
249 port := opts.Port
250 if opts.IsUpdate {
251 fmt.Printf(" Updating existing deployment (port %d)\n", port)
252 } else {
253 if port == 0 {
254 port = st.AllocatePort(opts.Host)
255 }
256 fmt.Printf(" Allocated port: %d\n", port)
257 }
258
259 // Add PORT to env
260 opts.Env["PORT"] = strconv.Itoa(port)
261
262 client, err := ssh.Connect(opts.Host)
263 if err != nil {
264 return fmt.Errorf("error connecting to VPS: %w", err)
265 }
266 defer client.Close()
267
268 fmt.Println("-> Uploading binary...")
269 remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name)
270 if err := client.Upload(opts.Binary, remoteTmpPath); err != nil {
271 return fmt.Errorf("error uploading binary: %w", err)
272 }
273
274 fmt.Println("-> Creating system user...")
275 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name))
276
277 fmt.Println("-> Setting up directories...")
278 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
279 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
280 return fmt.Errorf("error creating work directory: %w", err)
281 }
282 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil {
283 return fmt.Errorf("error setting work directory ownership: %w", err)
284 }
285
286 fmt.Println("-> Installing binary...")
287 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
288 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
289 return fmt.Errorf("error moving binary: %w", err)
290 }
291 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
292 return fmt.Errorf("error making binary executable: %w", err)
293 }
294
295 if len(opts.Files) > 0 {
296 fmt.Println("-> Uploading config files...")
297 for _, file := range opts.Files {
298 if _, err := os.Stat(file); err != nil {
299 return fmt.Errorf("config file not found: %s", file)
300 }
301
302 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
303 fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file))
304
305 if err := client.Upload(file, fileTmpPath); err != nil {
306 return fmt.Errorf("error uploading config file %s: %w", file, err)
307 }
308
309 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil {
310 return fmt.Errorf("error moving config file %s: %w", file, err)
311 }
312
313 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil {
314 return fmt.Errorf("error setting config file ownership %s: %w", file, err)
315 }
316
317 fmt.Printf(" Uploaded: %s\n", file)
318 }
319 }
320
321 fmt.Println("-> Creating environment file...")
322 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
323 envContent := ""
324 for k, v := range opts.Env {
325 envContent += fmt.Sprintf("%s=%s\n", k, v)
326 }
327 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
328 return fmt.Errorf("error creating env file: %w", err)
329 }
330 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
331 return fmt.Errorf("error setting env file permissions: %w", err)
332 }
333 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil {
334 return fmt.Errorf("error setting env file ownership: %w", err)
335 }
336
337 fmt.Println("-> Creating systemd service...")
338 serviceContent, err := templates.SystemdService(map[string]string{
339 "Name": opts.Name,
340 "User": opts.Name,
341 "WorkDir": workDir,
342 "BinaryPath": binaryDest,
343 "Port": strconv.Itoa(port),
344 "EnvFile": envFilePath,
345 "Args": opts.Args,
346 "Memory": opts.Memory,
347 "CPU": opts.CPU,
348 })
349 if err != nil {
350 return fmt.Errorf("error generating systemd unit: %w", err)
351 }
352
353 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
354 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
355 return fmt.Errorf("error creating systemd unit: %w", err)
356 }
357
358 fmt.Println("-> Configuring Caddy...")
359 caddyContent, err := templates.AppCaddy(map[string]string{
360 "Domain": opts.Domain,
361 "Port": strconv.Itoa(port),
362 })
363 if err != nil {
364 return fmt.Errorf("error generating Caddy config: %w", err)
365 }
366
367 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
368 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
369 return fmt.Errorf("error creating Caddy config: %w", err)
370 }
371
372 fmt.Println("-> Reloading systemd...")
373 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
374 return fmt.Errorf("error reloading systemd: %w", err)
375 }
376
377 fmt.Println("-> Starting service...")
378 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil {
379 return fmt.Errorf("error enabling service: %w", err)
380 }
381 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
382 return fmt.Errorf("error starting service: %w", err)
383 }
384
385 fmt.Println("-> Reloading Caddy...")
386 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
387 return fmt.Errorf("error reloading Caddy: %w", err)
388 }
389
390 st.AddApp(opts.Host, opts.Name, &state.App{
391 Type: "app",
392 Domain: opts.Domain,
393 Port: port,
394 Env: opts.Env,
395 Args: opts.Args,
396 Files: opts.Files,
397 Memory: opts.Memory,
398 CPU: opts.CPU,
399 })
400 if err := st.Save(); err != nil {
401 return fmt.Errorf("error saving state: %w", err)
402 }
403
404 fmt.Printf("\n App deployed successfully!\n")
405 // Show first domain in the URL message
406 primaryDomain := strings.Split(opts.Domain, ",")[0]
407 primaryDomain = strings.TrimSpace(primaryDomain)
408 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
409 return nil
410}
411
412func updateAppConfig(st *state.State, opts DeployOptions) error {
413 existingApp, err := st.GetApp(opts.Host, opts.Name)
414 if err != nil {
415 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name)
416 }
417
418 if existingApp.Type != "app" {
419 return fmt.Errorf("%s is a static site, not an app", opts.Name)
420 }
421
422 fmt.Printf("Updating config: %s\n", opts.Name)
423
424 // Add PORT to env
425 opts.Env["PORT"] = strconv.Itoa(existingApp.Port)
426
427 client, err := ssh.Connect(opts.Host)
428 if err != nil {
429 return fmt.Errorf("error connecting to VPS: %w", err)
430 }
431 defer client.Close()
432
433 // Update env file
434 fmt.Println("-> Updating environment file...")
435 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
436 envContent := ""
437 for k, v := range opts.Env {
438 envContent += fmt.Sprintf("%s=%s\n", k, v)
439 }
440 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
441 return fmt.Errorf("error creating env file: %w", err)
442 }
443
444 // Regenerate systemd unit
445 fmt.Println("-> Updating systemd service...")
446 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
447 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
448 serviceContent, err := templates.SystemdService(map[string]string{
449 "Name": opts.Name,
450 "User": opts.Name,
451 "WorkDir": workDir,
452 "BinaryPath": binaryDest,
453 "Port": strconv.Itoa(existingApp.Port),
454 "EnvFile": envFilePath,
455 "Args": opts.Args,
456 "Memory": opts.Memory,
457 "CPU": opts.CPU,
458 })
459 if err != nil {
460 return fmt.Errorf("error generating systemd unit: %w", err)
461 }
462
463 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
464 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
465 return fmt.Errorf("error creating systemd unit: %w", err)
466 }
467
468 fmt.Println("-> Reloading systemd...")
469 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
470 return fmt.Errorf("error reloading systemd: %w", err)
471 }
472
473 fmt.Println("-> Restarting service...")
474 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
475 return fmt.Errorf("error restarting service: %w", err)
476 }
477
478 // Update state
479 existingApp.Args = opts.Args
480 existingApp.Memory = opts.Memory
481 existingApp.CPU = opts.CPU
482 existingApp.Env = opts.Env
483 if err := st.Save(); err != nil {
484 return fmt.Errorf("error saving state: %w", err)
485 }
486
487 fmt.Printf("\n Config updated successfully!\n")
488 return nil
489}
490
491func deployStatic(st *state.State, opts DeployOptions) error {
492 if _, err := os.Stat(opts.Dir); err != nil {
493 return fmt.Errorf("directory not found: %s", opts.Dir)
494 }
495
496 fmt.Printf("Deploying static site: %s\n", opts.Name)
497 fmt.Printf(" Domain(s): %s\n", opts.Domain)
498 fmt.Printf(" Directory: %s\n", opts.Dir)
499
500 if opts.IsUpdate {
501 fmt.Println(" Updating existing deployment")
502 }
503
504 client, err := ssh.Connect(opts.Host)
505 if err != nil {
506 return fmt.Errorf("error connecting to VPS: %w", err)
507 }
508 defer client.Close()
509
510 remoteDir := fmt.Sprintf("/var/www/%s", opts.Name)
511 fmt.Println("-> Creating remote directory...")
512 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
513 return fmt.Errorf("error creating remote directory: %w", err)
514 }
515
516 currentUser, err := client.Run("whoami")
517 if err != nil {
518 return fmt.Errorf("error getting current user: %w", err)
519 }
520 currentUser = strings.TrimSpace(currentUser)
521
522 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
523 return fmt.Errorf("error setting temporary ownership: %w", err)
524 }
525
526 fmt.Println("-> Uploading files...")
527 if err := client.UploadDir(opts.Dir, remoteDir); err != nil {
528 return fmt.Errorf("error uploading files: %w", err)
529 }
530
531 fmt.Println("-> Setting permissions...")
532 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
533 return fmt.Errorf("error setting ownership: %w", err)
534 }
535 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
536 return fmt.Errorf("error setting directory permissions: %w", err)
537 }
538 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
539 return fmt.Errorf("error setting file permissions: %w", err)
540 }
541
542 fmt.Println("-> Configuring Caddy...")
543 caddyContent, err := templates.StaticCaddy(map[string]string{
544 "Domain": opts.Domain,
545 "RootDir": remoteDir,
546 })
547 if err != nil {
548 return fmt.Errorf("error generating Caddy config: %w", err)
549 }
550
551 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
552 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
553 return fmt.Errorf("error creating Caddy config: %w", err)
554 }
555
556 fmt.Println("-> Reloading Caddy...")
557 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
558 return fmt.Errorf("error reloading Caddy: %w", err)
559 }
560
561 st.AddApp(opts.Host, opts.Name, &state.App{
562 Type: "static",
563 Domain: opts.Domain,
564 })
565 if err := st.Save(); err != nil {
566 return fmt.Errorf("error saving state: %w", err)
567 }
568
569 fmt.Printf("\n Static site deployed successfully!\n")
570 primaryDomain := strings.Split(opts.Domain, ",")[0]
571 primaryDomain = strings.TrimSpace(primaryDomain)
572 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
573 return nil
574}
575
576func parseEnvFile(path string) (map[string]string, error) {
577 file, err := os.Open(path)
578 if err != nil {
579 return nil, err
580 }
581 defer file.Close()
582
583 env := make(map[string]string)
584 scanner := bufio.NewScanner(file)
585 for scanner.Scan() {
586 line := strings.TrimSpace(scanner.Text())
587 if line == "" || strings.HasPrefix(line, "#") {
588 continue
589 }
590
591 parts := strings.SplitN(line, "=", 2)
592 if len(parts) == 2 {
593 env[parts[0]] = parts[1]
594 }
595 }
596
597 return env, scanner.Err()
598}
diff --git a/cmd/ship/main.go b/cmd/ship/main.go
index cd2b0c1..f7d95c1 100644
--- a/cmd/ship/main.go
+++ b/cmd/ship/main.go
@@ -3,100 +3,8 @@ package main
3import ( 3import (
4 "fmt" 4 "fmt"
5 "os" 5 "os"
6
7 "github.com/bdw/ship/cmd/ship/env"
8 "github.com/bdw/ship/cmd/ship/host"
9 "github.com/spf13/cobra"
10) 6)
11 7
12var (
13 // Persistent flags
14 hostFlag string
15
16 // Version info (set via ldflags)
17 version = "dev"
18 commit = "none"
19 date = "unknown"
20)
21
22const banner = `
23 ~
24 ___|___
25 | _ |
26 _|__|_|__|_
27 | SHIP | Ship apps to your VPS
28 \_________/ with automatic HTTPS
29 ~~~~~~~~~
30`
31
32var rootCmd = &cobra.Command{
33 Use: "ship",
34 Short: "Ship apps and static sites to a VPS with automatic HTTPS",
35 Long: banner + `
36A CLI tool for deploying applications and static sites to a VPS.
37
38How it works:
39 Ship uses only SSH to deploy - no agents, containers, or external services.
40 It uploads your binary or static website, creates a systemd service, and configures Caddy
41 for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS
42 with minimal overhead.
43
44Requirements:
45 • A VPS with SSH access (use 'ship host init' to set up a new server)
46 • An SSH config entry or user@host for your server
47 • A domain pointing to your VPS
48
49Examples:
50 # Deploy a Go binary
51 ship --binary ./myapp --domain api.example.com
52
53 # Deploy with auto-generated subdomain (requires base domain)
54 ship --binary ./myapp --name myapp
55
56 # Deploy a static site
57 ship --static --dir ./dist --domain example.com
58
59 # Update config without redeploying binary
60 ship --name myapp --memory 512M --cpu 50%
61 ship --name myapp --env DEBUG=true
62
63 # Set up a new VPS with base domain
64 ship host init --host user@vps --base-domain apps.example.com`,
65 RunE: runDeploy,
66 SilenceUsage: true,
67 SilenceErrors: true,
68}
69
70func init() {
71 // Persistent flags available to all subcommands
72 rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)")
73
74 // Root command (deploy) flags
75 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)")
76 rootCmd.Flags().Bool("static", false, "Deploy as static site")
77 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)")
78 rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)")
79 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)")
80 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)")
81 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)")
82 rootCmd.Flags().String("env-file", "", "Path to .env file")
83 rootCmd.Flags().String("args", "", "Arguments to pass to binary")
84 rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)")
85 rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)")
86 rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)")
87
88 // Add subcommands
89 rootCmd.AddCommand(listCmd)
90 rootCmd.AddCommand(logsCmd)
91 rootCmd.AddCommand(statusCmd)
92 rootCmd.AddCommand(restartCmd)
93 rootCmd.AddCommand(removeCmd)
94 rootCmd.AddCommand(env.Cmd)
95 rootCmd.AddCommand(host.Cmd)
96 rootCmd.AddCommand(uiCmd)
97 rootCmd.AddCommand(versionCmd)
98}
99
100func main() { 8func main() {
101 if err := rootCmd.Execute(); err != nil { 9 if err := rootCmd.Execute(); err != nil {
102 fmt.Fprintf(os.Stderr, "Error: %v\n", err) 10 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
index 24eed1e..837fd4c 100644
--- a/cmd/ship/root.go
+++ b/cmd/ship/root.go
@@ -1,598 +1,95 @@
1package main 1package main
2 2
3import ( 3import (
4 "bufio" 4 "github.com/bdw/ship/cmd/ship/env"
5 "fmt" 5 "github.com/bdw/ship/cmd/ship/host"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10
11 "github.com/bdw/ship/internal/ssh"
12 "github.com/bdw/ship/internal/state"
13 "github.com/bdw/ship/internal/templates"
14 "github.com/spf13/cobra" 6 "github.com/spf13/cobra"
15) 7)
16 8
17// DeployOptions contains all options for deploying or updating an app 9var (
18type DeployOptions struct { 10 // Persistent flags
19 Host string 11 hostFlag string
20 Domain string
21 Name string
22 Binary string
23 Dir string // for static sites
24 Port int
25 Args string
26 Files []string
27 Memory string
28 CPU string
29 Env map[string]string // merged env vars
30 IsUpdate bool
31}
32
33func runDeploy(cmd *cobra.Command, args []string) error {
34 flags := cmd.Flags()
35
36 // Parse CLI flags
37 binary, _ := flags.GetString("binary")
38 static, _ := flags.GetBool("static")
39 dir, _ := flags.GetString("dir")
40 domain, _ := flags.GetString("domain")
41 name, _ := flags.GetString("name")
42 port, _ := flags.GetInt("port")
43 envVars, _ := flags.GetStringArray("env")
44 envFile, _ := flags.GetString("env-file")
45 argsFlag, _ := flags.GetString("args")
46 files, _ := flags.GetStringArray("file")
47 memory, _ := flags.GetString("memory")
48 cpu, _ := flags.GetString("cpu")
49
50 // Get host from flag or state default
51 host := hostFlag
52 if host == "" {
53 st, err := state.Load()
54 if err != nil {
55 return fmt.Errorf("error loading state: %w", err)
56 }
57 host = st.GetDefaultHost()
58 }
59
60 // If no flags provided, show help
61 if domain == "" && binary == "" && !static && name == "" {
62 return cmd.Help()
63 }
64
65 if host == "" {
66 return fmt.Errorf("--host is required")
67 }
68
69 // Load state once - this will be used throughout
70 st, err := state.Load()
71 if err != nil {
72 return fmt.Errorf("error loading state: %w", err)
73 }
74 hostState := st.GetHost(host)
75
76 // Config update mode: --name provided without --binary or --static
77 if name != "" && binary == "" && !static {
78 existingApp, err := st.GetApp(host, name)
79 if err != nil {
80 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name)
81 }
82
83 // Build merged config starting from existing app
84 opts := DeployOptions{
85 Host: host,
86 Name: name,
87 Port: existingApp.Port,
88 Args: existingApp.Args,
89 Files: existingApp.Files,
90 Memory: existingApp.Memory,
91 CPU: existingApp.CPU,
92 Env: make(map[string]string),
93 }
94 for k, v := range existingApp.Env {
95 opts.Env[k] = v
96 }
97
98 // Override with CLI flags if provided
99 if argsFlag != "" {
100 opts.Args = argsFlag
101 }
102 if len(files) > 0 {
103 opts.Files = files
104 }
105 if memory != "" {
106 opts.Memory = memory
107 }
108 if cpu != "" {
109 opts.CPU = cpu
110 }
111
112 // Merge env vars (CLI overrides existing)
113 for _, e := range envVars {
114 parts := strings.SplitN(e, "=", 2)
115 if len(parts) == 2 {
116 opts.Env[parts[0]] = parts[1]
117 }
118 }
119 if envFile != "" {
120 fileEnv, err := parseEnvFile(envFile)
121 if err != nil {
122 return fmt.Errorf("error reading env file: %w", err)
123 }
124 for k, v := range fileEnv {
125 opts.Env[k] = v
126 }
127 }
128
129 return updateAppConfig(st, opts)
130 }
131
132 // Infer name early so we can use it for subdomain generation and existing app lookup
133 if name == "" {
134 if static {
135 name = domain
136 if name == "" && hostState.BaseDomain != "" {
137 name = filepath.Base(dir)
138 }
139 } else {
140 name = filepath.Base(binary)
141 }
142 }
143
144 // Check if this is an update to an existing app/site
145 existingApp, _ := st.GetApp(host, name)
146 isUpdate := existingApp != nil
147
148 // For new deployments, require domain or base domain
149 if !isUpdate && domain == "" && hostState.BaseDomain == "" {
150 return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')")
151 }
152
153 // Build merged config, starting from existing app if updating
154 opts := DeployOptions{
155 Host: host,
156 Name: name,
157 Binary: binary,
158 Dir: dir,
159 IsUpdate: isUpdate,
160 }
161
162 // Merge domain: auto-subdomain + (user-provided or existing custom domain)
163 var domains []string
164 if hostState.BaseDomain != "" {
165 domains = append(domains, name+"."+hostState.BaseDomain)
166 }
167 if domain != "" {
168 domains = append(domains, domain)
169 } else if isUpdate && existingApp.Domain != "" {
170 for _, d := range strings.Split(existingApp.Domain, ",") {
171 d = strings.TrimSpace(d)
172 if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) {
173 domains = append(domains, d)
174 }
175 }
176 }
177 opts.Domain = strings.Join(domains, ", ")
178
179 // For apps, merge all config fields
180 if !static {
181 // Start with existing values if updating
182 if isUpdate {
183 opts.Port = existingApp.Port
184 opts.Args = existingApp.Args
185 opts.Files = existingApp.Files
186 opts.Memory = existingApp.Memory
187 opts.CPU = existingApp.CPU
188 opts.Env = make(map[string]string)
189 for k, v := range existingApp.Env {
190 opts.Env[k] = v
191 }
192 } else {
193 opts.Port = port
194 opts.Env = make(map[string]string)
195 }
196
197 // Override with CLI flags if provided
198 if argsFlag != "" {
199 opts.Args = argsFlag
200 }
201 if len(files) > 0 {
202 opts.Files = files
203 }
204 if memory != "" {
205 opts.Memory = memory
206 }
207 if cpu != "" {
208 opts.CPU = cpu
209 }
210
211 // Merge env vars (CLI overrides existing)
212 for _, e := range envVars {
213 parts := strings.SplitN(e, "=", 2)
214 if len(parts) == 2 {
215 opts.Env[parts[0]] = parts[1]
216 }
217 }
218 if envFile != "" {
219 fileEnv, err := parseEnvFile(envFile)
220 if err != nil {
221 return fmt.Errorf("error reading env file: %w", err)
222 }
223 for k, v := range fileEnv {
224 opts.Env[k] = v
225 }
226 }
227 }
228
229 if static {
230 return deployStatic(st, opts)
231 }
232 return deployApp(st, opts)
233}
234
235func deployApp(st *state.State, opts DeployOptions) error {
236 if opts.Binary == "" {
237 return fmt.Errorf("--binary is required")
238 }
239
240 if _, err := os.Stat(opts.Binary); err != nil {
241 return fmt.Errorf("binary not found: %s", opts.Binary)
242 }
243
244 fmt.Printf("Deploying app: %s\n", opts.Name)
245 fmt.Printf(" Domain(s): %s\n", opts.Domain)
246 fmt.Printf(" Binary: %s\n", opts.Binary)
247
248 // Allocate port for new apps
249 port := opts.Port
250 if opts.IsUpdate {
251 fmt.Printf(" Updating existing deployment (port %d)\n", port)
252 } else {
253 if port == 0 {
254 port = st.AllocatePort(opts.Host)
255 }
256 fmt.Printf(" Allocated port: %d\n", port)
257 }
258
259 // Add PORT to env
260 opts.Env["PORT"] = strconv.Itoa(port)
261
262 client, err := ssh.Connect(opts.Host)
263 if err != nil {
264 return fmt.Errorf("error connecting to VPS: %w", err)
265 }
266 defer client.Close()
267
268 fmt.Println("-> Uploading binary...")
269 remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name)
270 if err := client.Upload(opts.Binary, remoteTmpPath); err != nil {
271 return fmt.Errorf("error uploading binary: %w", err)
272 }
273
274 fmt.Println("-> Creating system user...")
275 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name))
276
277 fmt.Println("-> Setting up directories...")
278 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
279 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
280 return fmt.Errorf("error creating work directory: %w", err)
281 }
282 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil {
283 return fmt.Errorf("error setting work directory ownership: %w", err)
284 }
285
286 fmt.Println("-> Installing binary...")
287 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
288 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
289 return fmt.Errorf("error moving binary: %w", err)
290 }
291 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
292 return fmt.Errorf("error making binary executable: %w", err)
293 }
294
295 if len(opts.Files) > 0 {
296 fmt.Println("-> Uploading config files...")
297 for _, file := range opts.Files {
298 if _, err := os.Stat(file); err != nil {
299 return fmt.Errorf("config file not found: %s", file)
300 }
301
302 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
303 fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file))
304
305 if err := client.Upload(file, fileTmpPath); err != nil {
306 return fmt.Errorf("error uploading config file %s: %w", file, err)
307 }
308
309 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil {
310 return fmt.Errorf("error moving config file %s: %w", file, err)
311 }
312
313 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil {
314 return fmt.Errorf("error setting config file ownership %s: %w", file, err)
315 }
316
317 fmt.Printf(" Uploaded: %s\n", file)
318 }
319 }
320
321 fmt.Println("-> Creating environment file...")
322 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
323 envContent := ""
324 for k, v := range opts.Env {
325 envContent += fmt.Sprintf("%s=%s\n", k, v)
326 }
327 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
328 return fmt.Errorf("error creating env file: %w", err)
329 }
330 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
331 return fmt.Errorf("error setting env file permissions: %w", err)
332 }
333 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil {
334 return fmt.Errorf("error setting env file ownership: %w", err)
335 }
336 12
337 fmt.Println("-> Creating systemd service...") 13 // Version info (set via ldflags)
338 serviceContent, err := templates.SystemdService(map[string]string{ 14 version = "dev"
339 "Name": opts.Name, 15 commit = "none"
340 "User": opts.Name, 16 date = "unknown"
341 "WorkDir": workDir, 17)
342 "BinaryPath": binaryDest,
343 "Port": strconv.Itoa(port),
344 "EnvFile": envFilePath,
345 "Args": opts.Args,
346 "Memory": opts.Memory,
347 "CPU": opts.CPU,
348 })
349 if err != nil {
350 return fmt.Errorf("error generating systemd unit: %w", err)
351 }
352
353 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
354 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
355 return fmt.Errorf("error creating systemd unit: %w", err)
356 }
357
358 fmt.Println("-> Configuring Caddy...")
359 caddyContent, err := templates.AppCaddy(map[string]string{
360 "Domain": opts.Domain,
361 "Port": strconv.Itoa(port),
362 })
363 if err != nil {
364 return fmt.Errorf("error generating Caddy config: %w", err)
365 }
366
367 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
368 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
369 return fmt.Errorf("error creating Caddy config: %w", err)
370 }
371
372 fmt.Println("-> Reloading systemd...")
373 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
374 return fmt.Errorf("error reloading systemd: %w", err)
375 }
376
377 fmt.Println("-> Starting service...")
378 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil {
379 return fmt.Errorf("error enabling service: %w", err)
380 }
381 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
382 return fmt.Errorf("error starting service: %w", err)
383 }
384
385 fmt.Println("-> Reloading Caddy...")
386 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
387 return fmt.Errorf("error reloading Caddy: %w", err)
388 }
389
390 st.AddApp(opts.Host, opts.Name, &state.App{
391 Type: "app",
392 Domain: opts.Domain,
393 Port: port,
394 Env: opts.Env,
395 Args: opts.Args,
396 Files: opts.Files,
397 Memory: opts.Memory,
398 CPU: opts.CPU,
399 })
400 if err := st.Save(); err != nil {
401 return fmt.Errorf("error saving state: %w", err)
402 }
403
404 fmt.Printf("\n App deployed successfully!\n")
405 // Show first domain in the URL message
406 primaryDomain := strings.Split(opts.Domain, ",")[0]
407 primaryDomain = strings.TrimSpace(primaryDomain)
408 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
409 return nil
410}
411
412func updateAppConfig(st *state.State, opts DeployOptions) error {
413 existingApp, err := st.GetApp(opts.Host, opts.Name)
414 if err != nil {
415 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name)
416 }
417
418 if existingApp.Type != "app" {
419 return fmt.Errorf("%s is a static site, not an app", opts.Name)
420 }
421
422 fmt.Printf("Updating config: %s\n", opts.Name)
423
424 // Add PORT to env
425 opts.Env["PORT"] = strconv.Itoa(existingApp.Port)
426
427 client, err := ssh.Connect(opts.Host)
428 if err != nil {
429 return fmt.Errorf("error connecting to VPS: %w", err)
430 }
431 defer client.Close()
432
433 // Update env file
434 fmt.Println("-> Updating environment file...")
435 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
436 envContent := ""
437 for k, v := range opts.Env {
438 envContent += fmt.Sprintf("%s=%s\n", k, v)
439 }
440 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
441 return fmt.Errorf("error creating env file: %w", err)
442 }
443
444 // Regenerate systemd unit
445 fmt.Println("-> Updating systemd service...")
446 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
447 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
448 serviceContent, err := templates.SystemdService(map[string]string{
449 "Name": opts.Name,
450 "User": opts.Name,
451 "WorkDir": workDir,
452 "BinaryPath": binaryDest,
453 "Port": strconv.Itoa(existingApp.Port),
454 "EnvFile": envFilePath,
455 "Args": opts.Args,
456 "Memory": opts.Memory,
457 "CPU": opts.CPU,
458 })
459 if err != nil {
460 return fmt.Errorf("error generating systemd unit: %w", err)
461 }
462
463 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
464 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
465 return fmt.Errorf("error creating systemd unit: %w", err)
466 }
467
468 fmt.Println("-> Reloading systemd...")
469 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
470 return fmt.Errorf("error reloading systemd: %w", err)
471 }
472
473 fmt.Println("-> Restarting service...")
474 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
475 return fmt.Errorf("error restarting service: %w", err)
476 }
477
478 // Update state
479 existingApp.Args = opts.Args
480 existingApp.Memory = opts.Memory
481 existingApp.CPU = opts.CPU
482 existingApp.Env = opts.Env
483 if err := st.Save(); err != nil {
484 return fmt.Errorf("error saving state: %w", err)
485 }
486
487 fmt.Printf("\n Config updated successfully!\n")
488 return nil
489}
490
491func deployStatic(st *state.State, opts DeployOptions) error {
492 if _, err := os.Stat(opts.Dir); err != nil {
493 return fmt.Errorf("directory not found: %s", opts.Dir)
494 }
495
496 fmt.Printf("Deploying static site: %s\n", opts.Name)
497 fmt.Printf(" Domain(s): %s\n", opts.Domain)
498 fmt.Printf(" Directory: %s\n", opts.Dir)
499
500 if opts.IsUpdate {
501 fmt.Println(" Updating existing deployment")
502 }
503
504 client, err := ssh.Connect(opts.Host)
505 if err != nil {
506 return fmt.Errorf("error connecting to VPS: %w", err)
507 }
508 defer client.Close()
509
510 remoteDir := fmt.Sprintf("/var/www/%s", opts.Name)
511 fmt.Println("-> Creating remote directory...")
512 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
513 return fmt.Errorf("error creating remote directory: %w", err)
514 }
515
516 currentUser, err := client.Run("whoami")
517 if err != nil {
518 return fmt.Errorf("error getting current user: %w", err)
519 }
520 currentUser = strings.TrimSpace(currentUser)
521
522 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
523 return fmt.Errorf("error setting temporary ownership: %w", err)
524 }
525
526 fmt.Println("-> Uploading files...")
527 if err := client.UploadDir(opts.Dir, remoteDir); err != nil {
528 return fmt.Errorf("error uploading files: %w", err)
529 }
530
531 fmt.Println("-> Setting permissions...")
532 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
533 return fmt.Errorf("error setting ownership: %w", err)
534 }
535 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
536 return fmt.Errorf("error setting directory permissions: %w", err)
537 }
538 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
539 return fmt.Errorf("error setting file permissions: %w", err)
540 }
541
542 fmt.Println("-> Configuring Caddy...")
543 caddyContent, err := templates.StaticCaddy(map[string]string{
544 "Domain": opts.Domain,
545 "RootDir": remoteDir,
546 })
547 if err != nil {
548 return fmt.Errorf("error generating Caddy config: %w", err)
549 }
550
551 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
552 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
553 return fmt.Errorf("error creating Caddy config: %w", err)
554 }
555
556 fmt.Println("-> Reloading Caddy...")
557 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
558 return fmt.Errorf("error reloading Caddy: %w", err)
559 }
560
561 st.AddApp(opts.Host, opts.Name, &state.App{
562 Type: "static",
563 Domain: opts.Domain,
564 })
565 if err := st.Save(); err != nil {
566 return fmt.Errorf("error saving state: %w", err)
567 }
568 18
569 fmt.Printf("\n Static site deployed successfully!\n") 19const banner = `
570 primaryDomain := strings.Split(opts.Domain, ",")[0] 20 ~
571 primaryDomain = strings.TrimSpace(primaryDomain) 21 ___|___
572 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) 22 | _ |
573 return nil 23 _|__|_|__|_
24 | SHIP | Ship apps to your VPS
25 \_________/ with automatic HTTPS
26 ~~~~~~~~~
27`
28
29var rootCmd = &cobra.Command{
30 Use: "ship",
31 Short: "Ship apps and static sites to a VPS with automatic HTTPS",
32 Long: banner + `
33A CLI tool for deploying applications and static sites to a VPS.
34
35How it works:
36 Ship uses only SSH to deploy - no agents, containers, or external services.
37 It uploads your binary or static website, creates a systemd service, and configures Caddy
38 for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS
39 with minimal overhead.
40
41Requirements:
42 • A VPS with SSH access (use 'ship host init' to set up a new server)
43 • An SSH config entry or user@host for your server
44 • A domain pointing to your VPS
45
46Examples:
47 # Deploy a Go binary
48 ship --binary ./myapp --domain api.example.com
49
50 # Deploy with auto-generated subdomain (requires base domain)
51 ship --binary ./myapp --name myapp
52
53 # Deploy a static site
54 ship --static --dir ./dist --domain example.com
55
56 # Update config without redeploying binary
57 ship --name myapp --memory 512M --cpu 50%
58 ship --name myapp --env DEBUG=true
59
60 # Set up a new VPS with base domain
61 ship host init --host user@vps --base-domain apps.example.com`,
62 RunE: runDeploy,
63 SilenceUsage: true,
64 SilenceErrors: true,
574} 65}
575 66
576func parseEnvFile(path string) (map[string]string, error) { 67func init() {
577 file, err := os.Open(path) 68 // Persistent flags available to all subcommands
578 if err != nil { 69 rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)")
579 return nil, err 70
580 } 71 // Root command (deploy) flags
581 defer file.Close() 72 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)")
582 73 rootCmd.Flags().Bool("static", false, "Deploy as static site")
583 env := make(map[string]string) 74 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)")
584 scanner := bufio.NewScanner(file) 75 rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)")
585 for scanner.Scan() { 76 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)")
586 line := strings.TrimSpace(scanner.Text()) 77 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)")
587 if line == "" || strings.HasPrefix(line, "#") { 78 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)")
588 continue 79 rootCmd.Flags().String("env-file", "", "Path to .env file")
589 } 80 rootCmd.Flags().String("args", "", "Arguments to pass to binary")
590 81 rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)")
591 parts := strings.SplitN(line, "=", 2) 82 rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)")
592 if len(parts) == 2 { 83 rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)")
593 env[parts[0]] = parts[1] 84
594 } 85 // Add subcommands
595 } 86 rootCmd.AddCommand(listCmd)
596 87 rootCmd.AddCommand(logsCmd)
597 return env, scanner.Err() 88 rootCmd.AddCommand(statusCmd)
89 rootCmd.AddCommand(restartCmd)
90 rootCmd.AddCommand(removeCmd)
91 rootCmd.AddCommand(env.Cmd)
92 rootCmd.AddCommand(host.Cmd)
93 rootCmd.AddCommand(uiCmd)
94 rootCmd.AddCommand(versionCmd)
598} 95}