summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PROGRESS.md31
-rw-r--r--internal/detect/detect.go105
-rw-r--r--internal/output/output.go222
3 files changed, 358 insertions, 0 deletions
diff --git a/PROGRESS.md b/PROGRESS.md
new file mode 100644
index 0000000..9e9029b
--- /dev/null
+++ b/PROGRESS.md
@@ -0,0 +1,31 @@
1# Ship v2 Rebuild Progress
2
3Tracking rebuilding ship for agent-first JSON interface.
4
5## Status: IN PROGRESS
6
7## Completed
8- [x] Design docs (SHIP_V2.md, SPEC.md)
9
10## Current Phase: Foundation
11- [ ] JSON output types and helpers
12- [ ] New CLI structure (`ship [PATH]` as primary)
13- [ ] Auto-detection logic
14- [ ] Error codes
15
16## Upcoming
17- [ ] Deploy flows (static, docker, binary)
18- [ ] Health checks
19- [ ] TTL support + cleanup timer
20- [ ] `ship host init` (update to match spec)
21- [ ] `ship list/status/logs/remove` with JSON output
22
23## Commits
24<!-- Log of commits as we go -->
25
26---
27
28## Notes
29- Branch: `agent-mode`
30- Keep v1 code for reference until v2 is working
31- Test with real deploys before merging to main
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..13e34a3
--- /dev/null
+++ b/internal/output/output.go
@@ -0,0 +1,222 @@
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 codes
108const (
109 ErrInvalidPath = "INVALID_PATH"
110 ErrUnknownProjectType = "UNKNOWN_PROJECT_TYPE"
111 ErrSSHConnectFailed = "SSH_CONNECT_FAILED"
112 ErrSSHAuthFailed = "SSH_AUTH_FAILED"
113 ErrUploadFailed = "UPLOAD_FAILED"
114 ErrBuildFailed = "BUILD_FAILED"
115 ErrServiceFailed = "SERVICE_FAILED"
116 ErrCaddyFailed = "CADDY_FAILED"
117 ErrHealthCheckFailed = "HEALTH_CHECK_FAILED"
118 ErrHealthCheckTimeout = "HEALTH_CHECK_TIMEOUT"
119 ErrNotFound = "NOT_FOUND"
120 ErrConflict = "CONFLICT"
121 ErrHostNotConfigured = "HOST_NOT_CONFIGURED"
122 ErrInvalidTTL = "INVALID_TTL"
123 ErrInvalidName = "INVALID_NAME"
124 ErrPortExhausted = "PORT_EXHAUSTED"
125)
126
127// Exit codes
128const (
129 ExitSuccess = 0
130 ExitDeployFailed = 1
131 ExitInvalidArgs = 2
132 ExitSSHFailed = 3
133 ExitHealthFailed = 4
134)
135
136// Pretty controls human-readable output
137var Pretty bool
138
139// Print outputs the response as JSON (or pretty if enabled)
140func Print(r Response) {
141 if Pretty {
142 printPretty(r)
143 return
144 }
145 enc := json.NewEncoder(os.Stdout)
146 enc.Encode(r)
147}
148
149// PrintAndExit outputs the response and exits with appropriate code
150func PrintAndExit(r Response) {
151 Print(r)
152 if r.IsError() {
153 os.Exit(exitCodeForError(r.(*ErrorResponse).Code))
154 }
155 os.Exit(ExitSuccess)
156}
157
158// Err creates an ErrorResponse
159func Err(code, message string) *ErrorResponse {
160 return &ErrorResponse{
161 Status: "error",
162 Code: code,
163 Message: message,
164 }
165}
166
167// ErrWithName creates an ErrorResponse with name context
168func ErrWithName(code, message, name string) *ErrorResponse {
169 return &ErrorResponse{
170 Status: "error",
171 Code: code,
172 Message: message,
173 Name: name,
174 }
175}
176
177func exitCodeForError(code string) int {
178 switch code {
179 case ErrSSHConnectFailed, ErrSSHAuthFailed:
180 return ExitSSHFailed
181 case ErrHealthCheckFailed, ErrHealthCheckTimeout:
182 return ExitHealthFailed
183 case ErrInvalidPath, ErrInvalidTTL, ErrInvalidName, ErrHostNotConfigured:
184 return ExitInvalidArgs
185 default:
186 return ExitDeployFailed
187 }
188}
189
190func printPretty(r Response) {
191 switch v := r.(type) {
192 case *DeployResponse:
193 fmt.Printf("✓ Deployed to %s (%.1fs)\n", v.URL, float64(v.TookMs)/1000)
194 case *ListResponse:
195 if len(v.Deploys) == 0 {
196 fmt.Println("No deployments")
197 return
198 }
199 fmt.Printf("%-20s %-40s %-8s %s\n", "NAME", "URL", "TYPE", "STATUS")
200 for _, d := range v.Deploys {
201 status := "running"
202 if !d.Running {
203 status = "stopped"
204 }
205 if d.Expires != "" {
206 status += " (expires " + d.Expires + ")"
207 }
208 fmt.Printf("%-20s %-40s %-8s %s\n", d.Name, d.URL, d.Type, status)
209 }
210 case *RemoveResponse:
211 fmt.Printf("✓ Removed %s\n", v.Name)
212 case *ErrorResponse:
213 fmt.Printf("✗ %s: %s\n", v.Code, v.Message)
214 case *HostInitResponse:
215 fmt.Printf("✓ Initialized %s with domain %s\n", v.Host, v.Domain)
216 default:
217 // Fallback to JSON
218 enc := json.NewEncoder(os.Stdout)
219 enc.SetIndent("", " ")
220 enc.Encode(r)
221 }
222}