1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
|
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
)
// 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"`
BaseDomain string `json:"base_domain,omitempty"`
GitSetup bool `json:"git_setup,omitempty"`
Apps map[string]*App `json:"apps"`
}
// App represents a deployed application or static site
type App struct {
Type string `json:"type"` // "app", "static", "git-app", or "git-static"
Domain string `json:"domain"`
Port int `json:"port,omitempty"` // only for type="app" or "git-app"
Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git"
Public bool `json:"public,omitempty"` // only for git types, enables HTTP clone access
Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app"
Args string `json:"args,omitempty"` // only for type="app"
Files []string `json:"files,omitempty"` // only for type="app"
Memory string `json:"memory,omitempty"` // only for type="app"
CPU string `json:"cpu,omitempty"` // only for type="app"
}
const (
startPort = 8001
)
var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
// ValidateName checks that a name is safe for use in shell commands,
// file paths, systemd units, and DNS labels.
func ValidateName(name string) error {
if !validName.MatchString(name) {
return fmt.Errorf("invalid name %q: must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and be 1-63 characters", name)
}
return nil
}
// 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")
}
|