summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-02-17 08:11:19 -0800
committerClawd <ai@clawd.bot>2026-02-17 08:11:19 -0800
commit6f02ec84a8299fc5577f147cc8741c8a4b162b64 (patch)
tree020f3690e92732dcba723be0cfaef649f46de137 /internal
parent4b5a2656df13181b637c59c29ff31751e11cf22a (diff)
parent05ea98df57599775c1d5bfea336012b075531670 (diff)
Merge agent-mode: v2 rewrite complete
- Removed all v1 code (-2800 lines) - Simplified state to just default_host + base_domain - Atomic port allocation via flock - --container-port flag for Docker - Custom domains shown in ship list - Caddyfiles preserved on redeploy - JSON output by default, --pretty for humans
Diffstat (limited to 'internal')
-rw-r--r--internal/detect/detect.go105
-rw-r--r--internal/output/output.go226
-rw-r--r--internal/state/state.go83
-rw-r--r--internal/templates/templates.go2
4 files changed, 338 insertions, 78 deletions
diff --git a/internal/detect/detect.go b/internal/detect/detect.go
new file mode 100644
index 0000000..f3efd22
--- /dev/null
+++ b/internal/detect/detect.go
@@ -0,0 +1,105 @@
1// Package detect provides auto-detection of project types.
2package detect
3
4import (
5 "os"
6 "path/filepath"
7
8 "github.com/bdw/ship/internal/output"
9)
10
11// ProjectType represents the detected deployment type
12type ProjectType string
13
14const (
15 TypeStatic ProjectType = "static"
16 TypeDocker ProjectType = "docker"
17 TypeBinary ProjectType = "binary"
18)
19
20// Result is the detection outcome
21type Result struct {
22 Type ProjectType
23 Path string // Absolute path to deploy
24 Error *output.ErrorResponse
25}
26
27// Detect examines a path and determines how to deploy it.
28// Follows the logic from SPEC.md:
29// - File: must be executable → binary
30// - Directory with Dockerfile → docker
31// - Directory with index.html → static
32// - Go/Node without Dockerfile → error with guidance
33// - Empty or unknown → error
34func Detect(path string) Result {
35 absPath, err := filepath.Abs(path)
36 if err != nil {
37 return Result{Error: output.Err(output.ErrInvalidPath, "cannot resolve path: "+err.Error())}
38 }
39
40 info, err := os.Stat(absPath)
41 if err != nil {
42 if os.IsNotExist(err) {
43 return Result{Error: output.Err(output.ErrInvalidPath, "path does not exist: "+path)}
44 }
45 return Result{Error: output.Err(output.ErrInvalidPath, "cannot access path: "+err.Error())}
46 }
47
48 // File: must be executable binary
49 if !info.IsDir() {
50 return detectFile(absPath, info)
51 }
52
53 // Directory: check contents
54 return detectDirectory(absPath)
55}
56
57func detectFile(path string, info os.FileInfo) Result {
58 // Check if executable
59 if info.Mode()&0111 == 0 {
60 return Result{Error: output.Err(output.ErrUnknownProjectType, "file is not executable")}
61 }
62 return Result{Type: TypeBinary, Path: path}
63}
64
65func detectDirectory(path string) Result {
66 // Check for Dockerfile first (highest priority)
67 if hasFile(path, "Dockerfile") {
68 return Result{Type: TypeDocker, Path: path}
69 }
70
71 // Check for static site
72 if hasFile(path, "index.html") || hasFile(path, "index.htm") {
73 return Result{Type: TypeStatic, Path: path}
74 }
75
76 // Check for Go project without Dockerfile
77 if hasFile(path, "go.mod") {
78 return Result{Error: output.Err(output.ErrUnknownProjectType,
79 "Go project without Dockerfile. Add a Dockerfile or build a binary first.")}
80 }
81
82 // Check for Node project without Dockerfile
83 if hasFile(path, "package.json") {
84 return Result{Error: output.Err(output.ErrUnknownProjectType,
85 "Node project without Dockerfile. Add a Dockerfile.")}
86 }
87
88 // Check if empty
89 entries, err := os.ReadDir(path)
90 if err != nil {
91 return Result{Error: output.Err(output.ErrInvalidPath, "cannot read directory: "+err.Error())}
92 }
93 if len(entries) == 0 {
94 return Result{Error: output.Err(output.ErrInvalidPath, "directory is empty")}
95 }
96
97 // Unknown
98 return Result{Error: output.Err(output.ErrUnknownProjectType,
99 "cannot detect project type. Add a Dockerfile or index.html.")}
100}
101
102func hasFile(dir, name string) bool {
103 _, err := os.Stat(filepath.Join(dir, name))
104 return err == nil
105}
diff --git a/internal/output/output.go b/internal/output/output.go
new file mode 100644
index 0000000..a9a1036
--- /dev/null
+++ b/internal/output/output.go
@@ -0,0 +1,226 @@
1// Package output provides JSON response types for ship v2.
2// All commands output JSON by default. Human-readable output is opt-in via --pretty.
3package output
4
5import (
6 "encoding/json"
7 "fmt"
8 "os"
9)
10
11// Response is the base interface for all output types
12type Response interface {
13 IsError() bool
14}
15
16// DeployResponse is returned on successful deploy
17type DeployResponse struct {
18 Status string `json:"status"`
19 Name string `json:"name"`
20 URL string `json:"url"`
21 Type string `json:"type"` // "static", "docker", "binary"
22 TookMs int64 `json:"took_ms"`
23 Health *HealthResult `json:"health,omitempty"`
24 Expires string `json:"expires,omitempty"` // ISO 8601, only if TTL set
25}
26
27func (r DeployResponse) IsError() bool { return false }
28
29// HealthResult is the health check outcome
30type HealthResult struct {
31 Endpoint string `json:"endpoint"`
32 Status int `json:"status"`
33 LatencyMs int64 `json:"latency_ms"`
34}
35
36// ListResponse is returned by ship list
37type ListResponse struct {
38 Status string `json:"status"`
39 Deploys []DeployInfo `json:"deploys"`
40}
41
42func (r ListResponse) IsError() bool { return false }
43
44// DeployInfo is a single deploy in a list
45type DeployInfo struct {
46 Name string `json:"name"`
47 URL string `json:"url"`
48 Type string `json:"type"`
49 Running bool `json:"running"`
50 Expires string `json:"expires,omitempty"`
51}
52
53// StatusResponse is returned by ship status
54type StatusResponse struct {
55 Status string `json:"status"`
56 Name string `json:"name"`
57 URL string `json:"url"`
58 Type string `json:"type"`
59 Running bool `json:"running"`
60 Port int `json:"port,omitempty"`
61 Expires string `json:"expires,omitempty"`
62 Memory string `json:"memory,omitempty"`
63 CPU string `json:"cpu,omitempty"`
64}
65
66func (r StatusResponse) IsError() bool { return false }
67
68// LogsResponse is returned by ship logs
69type LogsResponse struct {
70 Status string `json:"status"`
71 Name string `json:"name"`
72 Lines []string `json:"lines"`
73}
74
75func (r LogsResponse) IsError() bool { return false }
76
77// RemoveResponse is returned by ship remove
78type RemoveResponse struct {
79 Status string `json:"status"`
80 Name string `json:"name"`
81 Removed bool `json:"removed"`
82}
83
84func (r RemoveResponse) IsError() bool { return false }
85
86// HostInitResponse is returned by ship host init
87type HostInitResponse struct {
88 Status string `json:"status"`
89 Host string `json:"host"`
90 Domain string `json:"domain"`
91 Installed []string `json:"installed"`
92}
93
94func (r HostInitResponse) IsError() bool { return false }
95
96// ErrorResponse is returned on any failure
97type ErrorResponse struct {
98 Status string `json:"status"` // always "error"
99 Code string `json:"code"`
100 Message string `json:"message"`
101 Name string `json:"name,omitempty"`
102 URL string `json:"url,omitempty"`
103}
104
105func (r ErrorResponse) IsError() bool { return true }
106
107// Error implements the error interface for compatibility with v1 code
108func (r *ErrorResponse) Error() string { return r.Message }
109
110// Error codes
111const (
112 ErrInvalidPath = "INVALID_PATH"
113 ErrInvalidArgs = "INVALID_ARGS"
114 ErrUnknownProjectType = "UNKNOWN_PROJECT_TYPE"
115 ErrSSHConnectFailed = "SSH_CONNECT_FAILED"
116 ErrSSHAuthFailed = "SSH_AUTH_FAILED"
117 ErrUploadFailed = "UPLOAD_FAILED"
118 ErrBuildFailed = "BUILD_FAILED"
119 ErrServiceFailed = "SERVICE_FAILED"
120 ErrCaddyFailed = "CADDY_FAILED"
121 ErrHealthCheckFailed = "HEALTH_CHECK_FAILED"
122 ErrHealthCheckTimeout = "HEALTH_CHECK_TIMEOUT"
123 ErrNotFound = "NOT_FOUND"
124 ErrConflict = "CONFLICT"
125 ErrHostNotConfigured = "HOST_NOT_CONFIGURED"
126 ErrInvalidTTL = "INVALID_TTL"
127 ErrInvalidName = "INVALID_NAME"
128 ErrPortExhausted = "PORT_EXHAUSTED"
129)
130
131// Exit codes
132const (
133 ExitSuccess = 0
134 ExitDeployFailed = 1
135 ExitInvalidArgs = 2
136 ExitSSHFailed = 3
137 ExitHealthFailed = 4
138)
139
140// Pretty controls human-readable output
141var Pretty bool
142
143// Print outputs the response as JSON (or pretty if enabled)
144func Print(r Response) {
145 if Pretty {
146 printPretty(r)
147 return
148 }
149 enc := json.NewEncoder(os.Stdout)
150 enc.Encode(r)
151}
152
153// PrintAndExit outputs the response and exits with appropriate code
154func PrintAndExit(r Response) {
155 Print(r)
156 if r.IsError() {
157 os.Exit(exitCodeForError(r.(*ErrorResponse).Code))
158 }
159 os.Exit(ExitSuccess)
160}
161
162// Err creates an ErrorResponse
163func Err(code, message string) *ErrorResponse {
164 return &ErrorResponse{
165 Status: "error",
166 Code: code,
167 Message: message,
168 }
169}
170
171// ErrWithName creates an ErrorResponse with name context
172func ErrWithName(code, message, name string) *ErrorResponse {
173 return &ErrorResponse{
174 Status: "error",
175 Code: code,
176 Message: message,
177 Name: name,
178 }
179}
180
181func exitCodeForError(code string) int {
182 switch code {
183 case ErrSSHConnectFailed, ErrSSHAuthFailed:
184 return ExitSSHFailed
185 case ErrHealthCheckFailed, ErrHealthCheckTimeout:
186 return ExitHealthFailed
187 case ErrInvalidPath, ErrInvalidTTL, ErrInvalidName, ErrHostNotConfigured, ErrInvalidArgs:
188 return ExitInvalidArgs
189 default:
190 return ExitDeployFailed
191 }
192}
193
194func printPretty(r Response) {
195 switch v := r.(type) {
196 case *DeployResponse:
197 fmt.Printf("✓ Deployed to %s (%.1fs)\n", v.URL, float64(v.TookMs)/1000)
198 case *ListResponse:
199 if len(v.Deploys) == 0 {
200 fmt.Println("No deployments")
201 return
202 }
203 fmt.Printf("%-20s %-40s %-8s %s\n", "NAME", "URL", "TYPE", "STATUS")
204 for _, d := range v.Deploys {
205 status := "running"
206 if !d.Running {
207 status = "stopped"
208 }
209 if d.Expires != "" {
210 status += " (expires " + d.Expires + ")"
211 }
212 fmt.Printf("%-20s %-40s %-8s %s\n", d.Name, d.URL, d.Type, status)
213 }
214 case *RemoveResponse:
215 fmt.Printf("✓ Removed %s\n", v.Name)
216 case *ErrorResponse:
217 fmt.Printf("✗ %s: %s\n", v.Code, v.Message)
218 case *HostInitResponse:
219 fmt.Printf("✓ Initialized %s with domain %s\n", v.Host, v.Domain)
220 default:
221 // Fallback to JSON
222 enc := json.NewEncoder(os.Stdout)
223 enc.SetIndent("", " ")
224 enc.Encode(r)
225 }
226}
diff --git a/internal/state/state.go b/internal/state/state.go
index c9aa21d..9b06179 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -8,38 +8,18 @@ import (
8 "regexp" 8 "regexp"
9) 9)
10 10
11// State represents the entire local deployment state 11// State represents the local ship configuration
12type State struct { 12type State struct {
13 DefaultHost string `json:"default_host,omitempty"` 13 DefaultHost string `json:"default_host,omitempty"`
14 Hosts map[string]*Host `json:"hosts"` 14 Hosts map[string]*Host `json:"hosts"`
15} 15}
16 16
17// Host represents deployment state for a single VPS 17// Host represents configuration for a single VPS
18type Host struct { 18type Host struct {
19 NextPort int `json:"next_port"` 19 BaseDomain string `json:"base_domain,omitempty"`
20 BaseDomain string `json:"base_domain,omitempty"` 20 GitSetup bool `json:"git_setup,omitempty"`
21 GitSetup bool `json:"git_setup,omitempty"`
22 Apps map[string]*App `json:"apps"`
23} 21}
24 22
25// App represents a deployed application or static site
26type App struct {
27 Type string `json:"type"` // "app", "static", "git-app", or "git-static"
28 Domain string `json:"domain"`
29 Port int `json:"port,omitempty"` // only for type="app" or "git-app"
30 Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git"
31 Public bool `json:"public,omitempty"` // only for git types, enables HTTP clone access
32 Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app"
33 Args string `json:"args,omitempty"` // only for type="app"
34 Files []string `json:"files,omitempty"` // only for type="app"
35 Memory string `json:"memory,omitempty"` // only for type="app"
36 CPU string `json:"cpu,omitempty"` // only for type="app"
37}
38
39const (
40 startPort = 8001
41)
42
43var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) 23var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
44 24
45// ValidateName checks that a name is safe for use in shell commands, 25// ValidateName checks that a name is safe for use in shell commands,
@@ -55,7 +35,6 @@ func ValidateName(name string) error {
55func Load() (*State, error) { 35func Load() (*State, error) {
56 path := statePath() 36 path := statePath()
57 37
58 // If file doesn't exist, return empty state
59 if _, err := os.Stat(path); os.IsNotExist(err) { 38 if _, err := os.Stat(path); os.IsNotExist(err) {
60 return &State{ 39 return &State{
61 Hosts: make(map[string]*Host), 40 Hosts: make(map[string]*Host),
@@ -72,7 +51,6 @@ func Load() (*State, error) {
72 return nil, fmt.Errorf("failed to parse state file: %w", err) 51 return nil, fmt.Errorf("failed to parse state file: %w", err)
73 } 52 }
74 53
75 // Initialize maps if nil
76 if state.Hosts == nil { 54 if state.Hosts == nil {
77 state.Hosts = make(map[string]*Host) 55 state.Hosts = make(map[string]*Host)
78 } 56 }
@@ -84,7 +62,6 @@ func Load() (*State, error) {
84func (s *State) Save() error { 62func (s *State) Save() error {
85 path := statePath() 63 path := statePath()
86 64
87 // Ensure directory exists
88 dir := filepath.Dir(path) 65 dir := filepath.Dir(path)
89 if err := os.MkdirAll(dir, 0755); err != nil { 66 if err := os.MkdirAll(dir, 0755); err != nil {
90 return fmt.Errorf("failed to create config directory: %w", err) 67 return fmt.Errorf("failed to create config directory: %w", err)
@@ -102,60 +79,14 @@ func (s *State) Save() error {
102 return nil 79 return nil
103} 80}
104 81
105// GetHost returns the host state, creating it if it doesn't exist 82// GetHost returns the host config, creating it if it doesn't exist
106func (s *State) GetHost(host string) *Host { 83func (s *State) GetHost(host string) *Host {
107 if s.Hosts[host] == nil { 84 if s.Hosts[host] == nil {
108 s.Hosts[host] = &Host{ 85 s.Hosts[host] = &Host{}
109 NextPort: startPort,
110 Apps: make(map[string]*App),
111 }
112 }
113 if s.Hosts[host].Apps == nil {
114 s.Hosts[host].Apps = make(map[string]*App)
115 } 86 }
116 return s.Hosts[host] 87 return s.Hosts[host]
117} 88}
118 89
119// AllocatePort returns the next available port for a host
120func (s *State) AllocatePort(host string) int {
121 h := s.GetHost(host)
122 port := h.NextPort
123 h.NextPort++
124 return port
125}
126
127// AddApp adds or updates an app in the state
128func (s *State) AddApp(host, name string, app *App) {
129 h := s.GetHost(host)
130 h.Apps[name] = app
131}
132
133// RemoveApp removes an app from the state
134func (s *State) RemoveApp(host, name string) error {
135 h := s.GetHost(host)
136 if _, exists := h.Apps[name]; !exists {
137 return fmt.Errorf("app %s not found", name)
138 }
139 delete(h.Apps, name)
140 return nil
141}
142
143// GetApp returns an app from the state
144func (s *State) GetApp(host, name string) (*App, error) {
145 h := s.GetHost(host)
146 app, exists := h.Apps[name]
147 if !exists {
148 return nil, fmt.Errorf("app %s not found", name)
149 }
150 return app, nil
151}
152
153// ListApps returns all apps for a host
154func (s *State) ListApps(host string) map[string]*App {
155 h := s.GetHost(host)
156 return h.Apps
157}
158
159// GetDefaultHost returns the default host, or empty string if not set 90// GetDefaultHost returns the default host, or empty string if not set
160func (s *State) GetDefaultHost() string { 91func (s *State) GetDefaultHost() string {
161 return s.DefaultHost 92 return s.DefaultHost
@@ -166,11 +97,9 @@ func (s *State) SetDefaultHost(host string) {
166 s.DefaultHost = host 97 s.DefaultHost = host
167} 98}
168 99
169// statePath returns the path to the state file
170func statePath() string { 100func statePath() string {
171 home, err := os.UserHomeDir() 101 home, err := os.UserHomeDir()
172 if err != nil { 102 if err != nil {
173 // Fallback to current directory (should rarely happen)
174 return ".ship-state.json" 103 return ".ship-state.json"
175 } 104 }
176 return filepath.Join(home, ".config", "ship", "state.json") 105 return filepath.Join(home, ".config", "ship", "state.json")
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
index b68a504..2163f47 100644
--- a/internal/templates/templates.go
+++ b/internal/templates/templates.go
@@ -216,7 +216,7 @@ Requires=docker.service
216Type=simple 216Type=simple
217ExecStartPre=-/usr/bin/docker rm -f {{.Name}} 217ExecStartPre=-/usr/bin/docker rm -f {{.Name}}
218ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ 218ExecStart=/usr/bin/docker run --rm --name {{.Name}} \
219 -p 127.0.0.1:{{.Port}}:{{.Port}} \ 219 -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \
220 --env-file /etc/ship/env/{{.Name}}.env \ 220 --env-file /etc/ship/env/{{.Name}}.env \
221 -v /var/lib/{{.Name}}/data:/data \ 221 -v /var/lib/{{.Name}}/data:/data \
222 {{.Name}}:latest 222 {{.Name}}:latest