summaryrefslogtreecommitdiffstats
path: root/cmd/ship/deploy.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship/deploy.go')
-rw-r--r--cmd/ship/deploy.go664
1 files changed, 0 insertions, 664 deletions
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go
deleted file mode 100644
index 414ade5..0000000
--- a/cmd/ship/deploy.go
+++ /dev/null
@@ -1,664 +0,0 @@
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 if err := validateName(name); err != nil {
144 return err
145 }
146
147 // Check if this is an update to an existing app/site
148 existingApp, _ := st.GetApp(host, name)
149 isUpdate := existingApp != nil
150
151 // For new deployments, require domain or base domain
152 if !isUpdate && domain == "" && hostState.BaseDomain == "" {
153 return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')")
154 }
155
156 // Build merged config, starting from existing app if updating
157 opts := DeployOptions{
158 Host: host,
159 Name: name,
160 Binary: binary,
161 Dir: dir,
162 IsUpdate: isUpdate,
163 }
164
165 // Merge domain: auto-subdomain + (user-provided or existing custom domain)
166 var domains []string
167 if hostState.BaseDomain != "" {
168 domains = append(domains, name+"."+hostState.BaseDomain)
169 }
170 if domain != "" {
171 domains = append(domains, domain)
172 } else if isUpdate && existingApp.Domain != "" {
173 for _, d := range strings.Split(existingApp.Domain, ",") {
174 d = strings.TrimSpace(d)
175 if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) {
176 domains = append(domains, d)
177 }
178 }
179 }
180 opts.Domain = strings.Join(domains, ", ")
181
182 // For apps, merge all config fields
183 if !static {
184 // Start with existing values if updating
185 if isUpdate {
186 opts.Port = existingApp.Port
187 opts.Args = existingApp.Args
188 opts.Files = existingApp.Files
189 opts.Memory = existingApp.Memory
190 opts.CPU = existingApp.CPU
191 opts.Env = make(map[string]string)
192 for k, v := range existingApp.Env {
193 opts.Env[k] = v
194 }
195 } else {
196 opts.Port = port
197 opts.Env = make(map[string]string)
198 }
199
200 // Override with CLI flags if provided
201 if argsFlag != "" {
202 opts.Args = argsFlag
203 }
204 if len(files) > 0 {
205 opts.Files = files
206 }
207 if memory != "" {
208 opts.Memory = memory
209 }
210 if cpu != "" {
211 opts.CPU = cpu
212 }
213
214 // Merge env vars (CLI overrides existing)
215 for _, e := range envVars {
216 parts := strings.SplitN(e, "=", 2)
217 if len(parts) == 2 {
218 opts.Env[parts[0]] = parts[1]
219 }
220 }
221 if envFile != "" {
222 fileEnv, err := parseEnvFile(envFile)
223 if err != nil {
224 return fmt.Errorf("error reading env file: %w", err)
225 }
226 for k, v := range fileEnv {
227 opts.Env[k] = v
228 }
229 }
230 }
231
232 if static {
233 return deployStatic(st, opts)
234 }
235 return deployApp(st, opts)
236}
237
238func deployApp(st *state.State, opts DeployOptions) error {
239 if opts.Binary == "" {
240 return fmt.Errorf("--binary is required")
241 }
242
243 if _, err := os.Stat(opts.Binary); err != nil {
244 return fmt.Errorf("binary not found: %s", opts.Binary)
245 }
246
247 fmt.Printf("Deploying app: %s\n", opts.Name)
248 fmt.Printf(" Domain(s): %s\n", opts.Domain)
249 fmt.Printf(" Binary: %s\n", opts.Binary)
250
251 // Allocate port for new apps
252 port := opts.Port
253 if opts.IsUpdate {
254 fmt.Printf(" Updating existing deployment (port %d)\n", port)
255 } else {
256 if port == 0 {
257 port = st.AllocatePort(opts.Host)
258 }
259 fmt.Printf(" Allocated port: %d\n", port)
260 }
261
262 // Add PORT to env
263 opts.Env["PORT"] = strconv.Itoa(port)
264
265 client, err := ssh.Connect(opts.Host)
266 if err != nil {
267 return fmt.Errorf("error connecting to VPS: %w", err)
268 }
269 defer client.Close()
270
271 fmt.Println("-> Uploading binary...")
272 remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name)
273 if err := client.Upload(opts.Binary, remoteTmpPath); err != nil {
274 return fmt.Errorf("error uploading binary: %w", err)
275 }
276
277 fmt.Println("-> Creating system user...")
278 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name))
279
280 fmt.Println("-> Setting up directories...")
281 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
282 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
283 return fmt.Errorf("error creating work directory: %w", err)
284 }
285 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil {
286 return fmt.Errorf("error setting work directory ownership: %w", err)
287 }
288
289 fmt.Println("-> Installing binary...")
290 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
291 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
292 return fmt.Errorf("error moving binary: %w", err)
293 }
294 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
295 return fmt.Errorf("error making binary executable: %w", err)
296 }
297
298 if len(opts.Files) > 0 {
299 fmt.Println("-> Uploading config files...")
300 for _, file := range opts.Files {
301 if _, err := os.Stat(file); err != nil {
302 return fmt.Errorf("config file not found: %s", file)
303 }
304
305 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
306 fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file))
307
308 if err := client.Upload(file, fileTmpPath); err != nil {
309 return fmt.Errorf("error uploading config file %s: %w", file, err)
310 }
311
312 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil {
313 return fmt.Errorf("error moving config file %s: %w", file, err)
314 }
315
316 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil {
317 return fmt.Errorf("error setting config file ownership %s: %w", file, err)
318 }
319
320 fmt.Printf(" Uploaded: %s\n", file)
321 }
322 }
323
324 fmt.Println("-> Creating environment file...")
325 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
326 envContent := ""
327 for k, v := range opts.Env {
328 envContent += fmt.Sprintf("%s=%s\n", k, v)
329 }
330 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
331 return fmt.Errorf("error creating env file: %w", err)
332 }
333 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
334 return fmt.Errorf("error setting env file permissions: %w", err)
335 }
336 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil {
337 return fmt.Errorf("error setting env file ownership: %w", err)
338 }
339
340 // Create local .ship directory for deployment configs if they don't exist
341 // (handles both initial deployment and migration of existing deployments)
342 if _, err := os.Stat(".ship/service"); os.IsNotExist(err) {
343 fmt.Println("-> Creating local .ship directory...")
344 if err := os.MkdirAll(".ship", 0755); err != nil {
345 return fmt.Errorf("error creating .ship directory: %w", err)
346 }
347
348 fmt.Println("-> Generating systemd service...")
349 serviceContent, err := templates.SystemdService(map[string]string{
350 "Name": opts.Name,
351 "User": opts.Name,
352 "WorkDir": workDir,
353 "BinaryPath": binaryDest,
354 "Port": strconv.Itoa(port),
355 "EnvFile": envFilePath,
356 "Args": opts.Args,
357 "Memory": opts.Memory,
358 "CPU": opts.CPU,
359 })
360 if err != nil {
361 return fmt.Errorf("error generating systemd unit: %w", err)
362 }
363 if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil {
364 return fmt.Errorf("error writing .ship/service: %w", err)
365 }
366 }
367
368 if _, err := os.Stat(".ship/Caddyfile"); os.IsNotExist(err) {
369 fmt.Println("-> Generating Caddyfile...")
370 caddyContent, err := templates.AppCaddy(map[string]string{
371 "Domain": opts.Domain,
372 "Port": strconv.Itoa(port),
373 })
374 if err != nil {
375 return fmt.Errorf("error generating Caddy config: %w", err)
376 }
377 if err := os.WriteFile(".ship/Caddyfile", []byte(caddyContent), 0644); err != nil {
378 return fmt.Errorf("error writing .ship/Caddyfile: %w", err)
379 }
380 }
381
382 // Upload systemd service from .ship/service
383 fmt.Println("-> Installing systemd service...")
384 serviceContent, err := os.ReadFile(".ship/service")
385 if err != nil {
386 return fmt.Errorf("error reading .ship/service: %w", err)
387 }
388 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
389 if err := client.WriteSudoFile(servicePath, string(serviceContent)); err != nil {
390 return fmt.Errorf("error installing systemd unit: %w", err)
391 }
392
393 // Upload Caddyfile from .ship/Caddyfile
394 fmt.Println("-> Installing Caddy config...")
395 caddyContent, err := os.ReadFile(".ship/Caddyfile")
396 if err != nil {
397 return fmt.Errorf("error reading .ship/Caddyfile: %w", err)
398 }
399 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
400 if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil {
401 return fmt.Errorf("error installing Caddy config: %w", err)
402 }
403
404 fmt.Println("-> Reloading systemd...")
405 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
406 return fmt.Errorf("error reloading systemd: %w", err)
407 }
408
409 fmt.Println("-> Starting service...")
410 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil {
411 return fmt.Errorf("error enabling service: %w", err)
412 }
413 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
414 return fmt.Errorf("error starting service: %w", err)
415 }
416
417 fmt.Println("-> Reloading Caddy...")
418 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
419 return fmt.Errorf("error reloading Caddy: %w", err)
420 }
421
422 st.AddApp(opts.Host, opts.Name, &state.App{
423 Type: "app",
424 Domain: opts.Domain,
425 Port: port,
426 Env: opts.Env,
427 Args: opts.Args,
428 Files: opts.Files,
429 Memory: opts.Memory,
430 CPU: opts.CPU,
431 })
432 if err := st.Save(); err != nil {
433 return fmt.Errorf("error saving state: %w", err)
434 }
435
436 fmt.Printf("\n App deployed successfully!\n")
437 // Show first domain in the URL message
438 primaryDomain := strings.Split(opts.Domain, ",")[0]
439 primaryDomain = strings.TrimSpace(primaryDomain)
440 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
441 return nil
442}
443
444func updateAppConfig(st *state.State, opts DeployOptions) error {
445 existingApp, err := st.GetApp(opts.Host, opts.Name)
446 if err != nil {
447 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name)
448 }
449
450 if existingApp.Type != "app" && existingApp.Type != "git-app" {
451 return fmt.Errorf("%s is a static site, not an app", opts.Name)
452 }
453
454 fmt.Printf("Updating config: %s\n", opts.Name)
455
456 // Add PORT to env
457 opts.Env["PORT"] = strconv.Itoa(existingApp.Port)
458
459 client, err := ssh.Connect(opts.Host)
460 if err != nil {
461 return fmt.Errorf("error connecting to VPS: %w", err)
462 }
463 defer client.Close()
464
465 // Update env file
466 fmt.Println("-> Updating environment file...")
467 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
468 envContent := ""
469 for k, v := range opts.Env {
470 envContent += fmt.Sprintf("%s=%s\n", k, v)
471 }
472 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
473 return fmt.Errorf("error creating env file: %w", err)
474 }
475
476 // For git-app, the systemd unit comes from .ship/service in the repo,
477 // so we only update the env file and restart.
478 if existingApp.Type != "git-app" {
479 // Regenerate systemd unit to .ship/service (resource flags are being updated)
480 fmt.Println("-> Updating systemd service...")
481 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
482 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
483 serviceContent, err := templates.SystemdService(map[string]string{
484 "Name": opts.Name,
485 "User": opts.Name,
486 "WorkDir": workDir,
487 "BinaryPath": binaryDest,
488 "Port": strconv.Itoa(existingApp.Port),
489 "EnvFile": envFilePath,
490 "Args": opts.Args,
491 "Memory": opts.Memory,
492 "CPU": opts.CPU,
493 })
494 if err != nil {
495 return fmt.Errorf("error generating systemd unit: %w", err)
496 }
497
498 // Write to local .ship/service
499 if err := os.MkdirAll(".ship", 0755); err != nil {
500 return fmt.Errorf("error creating .ship directory: %w", err)
501 }
502 if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil {
503 return fmt.Errorf("error writing .ship/service: %w", err)
504 }
505
506 // Upload to server
507 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
508 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
509 return fmt.Errorf("error installing systemd unit: %w", err)
510 }
511
512 fmt.Println("-> Reloading systemd...")
513 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
514 return fmt.Errorf("error reloading systemd: %w", err)
515 }
516 }
517
518 fmt.Println("-> Restarting service...")
519 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
520 return fmt.Errorf("error restarting service: %w", err)
521 }
522
523 // Update state
524 existingApp.Args = opts.Args
525 existingApp.Memory = opts.Memory
526 existingApp.CPU = opts.CPU
527 existingApp.Env = opts.Env
528 if err := st.Save(); err != nil {
529 return fmt.Errorf("error saving state: %w", err)
530 }
531
532 fmt.Printf("\n Config updated successfully!\n")
533 return nil
534}
535
536func deployStatic(st *state.State, opts DeployOptions) error {
537 if _, err := os.Stat(opts.Dir); err != nil {
538 return fmt.Errorf("directory not found: %s", opts.Dir)
539 }
540
541 fmt.Printf("Deploying static site: %s\n", opts.Name)
542 fmt.Printf(" Domain(s): %s\n", opts.Domain)
543 fmt.Printf(" Directory: %s\n", opts.Dir)
544
545 if opts.IsUpdate {
546 fmt.Println(" Updating existing deployment")
547 }
548
549 client, err := ssh.Connect(opts.Host)
550 if err != nil {
551 return fmt.Errorf("error connecting to VPS: %w", err)
552 }
553 defer client.Close()
554
555 remoteDir := fmt.Sprintf("/var/www/%s", opts.Name)
556 fmt.Println("-> Creating remote directory...")
557 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
558 return fmt.Errorf("error creating remote directory: %w", err)
559 }
560
561 currentUser, err := client.Run("whoami")
562 if err != nil {
563 return fmt.Errorf("error getting current user: %w", err)
564 }
565 currentUser = strings.TrimSpace(currentUser)
566
567 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
568 return fmt.Errorf("error setting temporary ownership: %w", err)
569 }
570
571 fmt.Println("-> Uploading files...")
572 if err := client.UploadDir(opts.Dir, remoteDir); err != nil {
573 return fmt.Errorf("error uploading files: %w", err)
574 }
575
576 fmt.Println("-> Setting permissions...")
577 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
578 return fmt.Errorf("error setting ownership: %w", err)
579 }
580 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
581 return fmt.Errorf("error setting directory permissions: %w", err)
582 }
583 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
584 return fmt.Errorf("error setting file permissions: %w", err)
585 }
586
587 // Create local .ship directory and Caddyfile for static sites if it doesn't exist
588 // (handles both initial deployment and migration of existing deployments)
589 shipDir := filepath.Join(opts.Dir, ".ship")
590 caddyfilePath := filepath.Join(shipDir, "Caddyfile")
591
592 if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
593 fmt.Println("-> Creating local .ship directory...")
594 if err := os.MkdirAll(shipDir, 0755); err != nil {
595 return fmt.Errorf("error creating .ship directory: %w", err)
596 }
597
598 fmt.Println("-> Generating Caddyfile...")
599 caddyContent, err := templates.StaticCaddy(map[string]string{
600 "Domain": opts.Domain,
601 "RootDir": remoteDir,
602 })
603 if err != nil {
604 return fmt.Errorf("error generating Caddy config: %w", err)
605 }
606 if err := os.WriteFile(caddyfilePath, []byte(caddyContent), 0644); err != nil {
607 return fmt.Errorf("error writing .ship/Caddyfile: %w", err)
608 }
609 }
610
611 // Upload Caddyfile from .ship/Caddyfile
612 fmt.Println("-> Installing Caddy config...")
613 caddyContent, err := os.ReadFile(caddyfilePath)
614 if err != nil {
615 return fmt.Errorf("error reading .ship/Caddyfile: %w", err)
616 }
617 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
618 if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil {
619 return fmt.Errorf("error installing Caddy config: %w", err)
620 }
621
622 fmt.Println("-> Reloading Caddy...")
623 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
624 return fmt.Errorf("error reloading Caddy: %w", err)
625 }
626
627 st.AddApp(opts.Host, opts.Name, &state.App{
628 Type: "static",
629 Domain: opts.Domain,
630 })
631 if err := st.Save(); err != nil {
632 return fmt.Errorf("error saving state: %w", err)
633 }
634
635 fmt.Printf("\n Static site deployed successfully!\n")
636 primaryDomain := strings.Split(opts.Domain, ",")[0]
637 primaryDomain = strings.TrimSpace(primaryDomain)
638 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
639 return nil
640}
641
642func parseEnvFile(path string) (map[string]string, error) {
643 file, err := os.Open(path)
644 if err != nil {
645 return nil, err
646 }
647 defer file.Close()
648
649 env := make(map[string]string)
650 scanner := bufio.NewScanner(file)
651 for scanner.Scan() {
652 line := strings.TrimSpace(scanner.Text())
653 if line == "" || strings.HasPrefix(line, "#") {
654 continue
655 }
656
657 parts := strings.SplitN(line, "=", 2)
658 if len(parts) == 2 {
659 env[parts[0]] = parts[1]
660 }
661 }
662
663 return env, scanner.Err()
664}