package state import ( "encoding/json" "fmt" "os" "path/filepath" ) // State represents the entire local deployment state type State struct { DefaultHost string `json:"default_host,omitempty"` Hosts map[string]*Host `json:"hosts"` } // Host represents deployment state for a single VPS type Host struct { NextPort int `json:"next_port"` Apps map[string]*App `json:"apps"` } // App represents a deployed application or static site type App struct { Type string `json:"type"` // "app" or "static" Domain string `json:"domain"` Port int `json:"port,omitempty"` // only for type="app" Env map[string]string `json:"env,omitempty"` // only for type="app" Args string `json:"args,omitempty"` // only for type="app" Files []string `json:"files,omitempty"` // only for type="app" } const ( startPort = 8001 ) // Load reads state from ~/.config/ship/state.json func Load() (*State, error) { path := statePath() // If file doesn't exist, return empty state if _, err := os.Stat(path); os.IsNotExist(err) { return &State{ Hosts: make(map[string]*Host), }, nil } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read state file: %w", err) } var state State if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("failed to parse state file: %w", err) } // Initialize maps if nil if state.Hosts == nil { state.Hosts = make(map[string]*Host) } return &state, nil } // Save writes state to ~/.config/ship/state.json func (s *State) Save() error { path := statePath() // Ensure directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } if err := os.WriteFile(path, data, 0600); err != nil { return fmt.Errorf("failed to write state file: %w", err) } return nil } // GetHost returns the host state, creating it if it doesn't exist func (s *State) GetHost(host string) *Host { if s.Hosts[host] == nil { s.Hosts[host] = &Host{ NextPort: startPort, Apps: make(map[string]*App), } } if s.Hosts[host].Apps == nil { s.Hosts[host].Apps = make(map[string]*App) } return s.Hosts[host] } // AllocatePort returns the next available port for a host func (s *State) AllocatePort(host string) int { h := s.GetHost(host) port := h.NextPort h.NextPort++ return port } // AddApp adds or updates an app in the state func (s *State) AddApp(host, name string, app *App) { h := s.GetHost(host) h.Apps[name] = app } // RemoveApp removes an app from the state func (s *State) RemoveApp(host, name string) error { h := s.GetHost(host) if _, exists := h.Apps[name]; !exists { return fmt.Errorf("app %s not found", name) } delete(h.Apps, name) return nil } // GetApp returns an app from the state func (s *State) GetApp(host, name string) (*App, error) { h := s.GetHost(host) app, exists := h.Apps[name] if !exists { return nil, fmt.Errorf("app %s not found", name) } return app, nil } // ListApps returns all apps for a host func (s *State) ListApps(host string) map[string]*App { h := s.GetHost(host) return h.Apps } // GetDefaultHost returns the default host, or empty string if not set func (s *State) GetDefaultHost() string { return s.DefaultHost } // SetDefaultHost sets the default host func (s *State) SetDefaultHost(host string) { s.DefaultHost = host } // statePath returns the path to the state file func statePath() string { home, err := os.UserHomeDir() if err != nil { // Fallback to current directory (should rarely happen) return ".ship-state.json" } return filepath.Join(home, ".config", "ship", "state.json") }