diff options
| author | bndw <ben@bdw.to> | 2026-01-25 10:16:23 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-01-25 10:16:23 -0800 |
| commit | af109c04a3edd4dcd4e7b16242052442fb4a3b24 (patch) | |
| tree | 518d4026a65ac8aeae2f1c12b4c964f9db72ee69 /cmd/ship | |
| parent | 92189aed1a4789e13e275caec4492aac04b7a3a2 (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')
| -rw-r--r-- | cmd/ship/deploy.go | 598 | ||||
| -rw-r--r-- | cmd/ship/main.go | 92 | ||||
| -rw-r--r-- | cmd/ship/root.go | 671 |
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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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 | ||
| 18 | type 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 | |||
| 33 | func 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 | |||
| 235 | func 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 | |||
| 412 | func 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 | |||
| 491 | func 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 | |||
| 576 | func 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 | |||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 12 | var ( | ||
| 13 | // Persistent flags | ||
| 14 | hostFlag string | ||
| 15 | |||
| 16 | // Version info (set via ldflags) | ||
| 17 | version = "dev" | ||
| 18 | commit = "none" | ||
| 19 | date = "unknown" | ||
| 20 | ) | ||
| 21 | |||
| 22 | const banner = ` | ||
| 23 | ~ | ||
| 24 | ___|___ | ||
| 25 | | _ | | ||
| 26 | _|__|_|__|_ | ||
| 27 | | SHIP | Ship apps to your VPS | ||
| 28 | \_________/ with automatic HTTPS | ||
| 29 | ~~~~~~~~~ | ||
| 30 | ` | ||
| 31 | |||
| 32 | var rootCmd = &cobra.Command{ | ||
| 33 | Use: "ship", | ||
| 34 | Short: "Ship apps and static sites to a VPS with automatic HTTPS", | ||
| 35 | Long: banner + ` | ||
| 36 | A CLI tool for deploying applications and static sites to a VPS. | ||
| 37 | |||
| 38 | How 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 | |||
| 44 | Requirements: | ||
| 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 | |||
| 49 | Examples: | ||
| 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 | |||
| 70 | func 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 | |||
| 100 | func main() { | 8 | func 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 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | 9 | var ( |
| 18 | type 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 | |||
| 33 | func 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 | |||
| 235 | func 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 | |||
| 412 | func 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 | |||
| 491 | func 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") | 19 | const 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 | |||
| 29 | var rootCmd = &cobra.Command{ | ||
| 30 | Use: "ship", | ||
| 31 | Short: "Ship apps and static sites to a VPS with automatic HTTPS", | ||
| 32 | Long: banner + ` | ||
| 33 | A CLI tool for deploying applications and static sites to a VPS. | ||
| 34 | |||
| 35 | How 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 | |||
| 41 | Requirements: | ||
| 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 | |||
| 46 | Examples: | ||
| 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 | ||
| 576 | func parseEnvFile(path string) (map[string]string, error) { | 67 | func 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 | } |
