summaryrefslogtreecommitdiffstats
path: root/cmd/ship/root.go
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-01-25 10:16:23 -0800
committerbndw <ben@bdw.to>2026-01-25 10:16:23 -0800
commitaf109c04a3edd4dcd4e7b16242052442fb4a3b24 (patch)
tree518d4026a65ac8aeae2f1c12b4c964f9db72ee69 /cmd/ship/root.go
parent92189aed1a4789e13e275caec4492aac04b7a3a2 (diff)
Move deploy implementation to deploy.go
Restructure CLI files to follow idiomatic cobra layout: - main.go: minimal entry point - root.go: command definition, flags, and subcommand wiring - deploy.go: all deploy implementation
Diffstat (limited to 'cmd/ship/root.go')
-rw-r--r--cmd/ship/root.go671
1 files changed, 84 insertions, 587 deletions
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}