diff options
Diffstat (limited to 'internal/state')
| -rw-r--r-- | internal/state/state.go | 146 |
1 files changed, 146 insertions, 0 deletions
diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..c7c7dd4 --- /dev/null +++ b/internal/state/state.go | |||
| @@ -0,0 +1,146 @@ | |||
| 1 | package state | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // State represents the entire local deployment state | ||
| 11 | type State struct { | ||
| 12 | Hosts map[string]*Host `json:"hosts"` | ||
| 13 | } | ||
| 14 | |||
| 15 | // Host represents deployment state for a single VPS | ||
| 16 | type Host struct { | ||
| 17 | NextPort int `json:"next_port"` | ||
| 18 | Apps map[string]*App `json:"apps"` | ||
| 19 | } | ||
| 20 | |||
| 21 | // App represents a deployed application or static site | ||
| 22 | type App struct { | ||
| 23 | Type string `json:"type"` // "app" or "static" | ||
| 24 | Domain string `json:"domain"` | ||
| 25 | Port int `json:"port,omitempty"` // only for type="app" | ||
| 26 | Env map[string]string `json:"env,omitempty"` // only for type="app" | ||
| 27 | } | ||
| 28 | |||
| 29 | const ( | ||
| 30 | startPort = 8001 | ||
| 31 | ) | ||
| 32 | |||
| 33 | // Load reads state from ~/.config/deploy/state.json | ||
| 34 | func Load() (*State, error) { | ||
| 35 | path := statePath() | ||
| 36 | |||
| 37 | // If file doesn't exist, return empty state | ||
| 38 | if _, err := os.Stat(path); os.IsNotExist(err) { | ||
| 39 | return &State{ | ||
| 40 | Hosts: make(map[string]*Host), | ||
| 41 | }, nil | ||
| 42 | } | ||
| 43 | |||
| 44 | data, err := os.ReadFile(path) | ||
| 45 | if err != nil { | ||
| 46 | return nil, fmt.Errorf("failed to read state file: %w", err) | ||
| 47 | } | ||
| 48 | |||
| 49 | var state State | ||
| 50 | if err := json.Unmarshal(data, &state); err != nil { | ||
| 51 | return nil, fmt.Errorf("failed to parse state file: %w", err) | ||
| 52 | } | ||
| 53 | |||
| 54 | // Initialize maps if nil | ||
| 55 | if state.Hosts == nil { | ||
| 56 | state.Hosts = make(map[string]*Host) | ||
| 57 | } | ||
| 58 | |||
| 59 | return &state, nil | ||
| 60 | } | ||
| 61 | |||
| 62 | // Save writes state to ~/.config/deploy/state.json | ||
| 63 | func (s *State) Save() error { | ||
| 64 | path := statePath() | ||
| 65 | |||
| 66 | // Ensure directory exists | ||
| 67 | dir := filepath.Dir(path) | ||
| 68 | if err := os.MkdirAll(dir, 0755); err != nil { | ||
| 69 | return fmt.Errorf("failed to create config directory: %w", err) | ||
| 70 | } | ||
| 71 | |||
| 72 | data, err := json.MarshalIndent(s, "", " ") | ||
| 73 | if err != nil { | ||
| 74 | return fmt.Errorf("failed to marshal state: %w", err) | ||
| 75 | } | ||
| 76 | |||
| 77 | if err := os.WriteFile(path, data, 0600); err != nil { | ||
| 78 | return fmt.Errorf("failed to write state file: %w", err) | ||
| 79 | } | ||
| 80 | |||
| 81 | return nil | ||
| 82 | } | ||
| 83 | |||
| 84 | // GetHost returns the host state, creating it if it doesn't exist | ||
| 85 | func (s *State) GetHost(host string) *Host { | ||
| 86 | if s.Hosts[host] == nil { | ||
| 87 | s.Hosts[host] = &Host{ | ||
| 88 | NextPort: startPort, | ||
| 89 | Apps: make(map[string]*App), | ||
| 90 | } | ||
| 91 | } | ||
| 92 | if s.Hosts[host].Apps == nil { | ||
| 93 | s.Hosts[host].Apps = make(map[string]*App) | ||
| 94 | } | ||
| 95 | return s.Hosts[host] | ||
| 96 | } | ||
| 97 | |||
| 98 | // AllocatePort returns the next available port for a host | ||
| 99 | func (s *State) AllocatePort(host string) int { | ||
| 100 | h := s.GetHost(host) | ||
| 101 | port := h.NextPort | ||
| 102 | h.NextPort++ | ||
| 103 | return port | ||
| 104 | } | ||
| 105 | |||
| 106 | // AddApp adds or updates an app in the state | ||
| 107 | func (s *State) AddApp(host, name string, app *App) { | ||
| 108 | h := s.GetHost(host) | ||
| 109 | h.Apps[name] = app | ||
| 110 | } | ||
| 111 | |||
| 112 | // RemoveApp removes an app from the state | ||
| 113 | func (s *State) RemoveApp(host, name string) error { | ||
| 114 | h := s.GetHost(host) | ||
| 115 | if _, exists := h.Apps[name]; !exists { | ||
| 116 | return fmt.Errorf("app %s not found", name) | ||
| 117 | } | ||
| 118 | delete(h.Apps, name) | ||
| 119 | return nil | ||
| 120 | } | ||
| 121 | |||
| 122 | // GetApp returns an app from the state | ||
| 123 | func (s *State) GetApp(host, name string) (*App, error) { | ||
| 124 | h := s.GetHost(host) | ||
| 125 | app, exists := h.Apps[name] | ||
| 126 | if !exists { | ||
| 127 | return nil, fmt.Errorf("app %s not found", name) | ||
| 128 | } | ||
| 129 | return app, nil | ||
| 130 | } | ||
| 131 | |||
| 132 | // ListApps returns all apps for a host | ||
| 133 | func (s *State) ListApps(host string) map[string]*App { | ||
| 134 | h := s.GetHost(host) | ||
| 135 | return h.Apps | ||
| 136 | } | ||
| 137 | |||
| 138 | // statePath returns the path to the state file | ||
| 139 | func statePath() string { | ||
| 140 | home, err := os.UserHomeDir() | ||
| 141 | if err != nil { | ||
| 142 | // Fallback to current directory (should rarely happen) | ||
| 143 | return ".deploy-state.json" | ||
| 144 | } | ||
| 145 | return filepath.Join(home, ".config", "deploy", "state.json") | ||
| 146 | } | ||
