summaryrefslogtreecommitdiffstats
path: root/internal/output
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-02-15 18:47:15 -0800
committerClawd <ai@clawd.bot>2026-02-15 18:47:15 -0800
commit5b8893550130ad8ffe39a6523a11994757493691 (patch)
tree123b50d2f0747078d3b8d1649be2ada25e2f177a /internal/output
parentf1d6907f098e1fe66a6b2f9e89abfe70707b53a9 (diff)
feat(v2): add output and detect packages
- internal/output: JSON response types, error codes, exit codes, pretty output - internal/detect: auto-detection of project type (static/docker/binary) - PROGRESS.md: track rebuild progress Foundation for agent-first JSON interface per SPEC.md
Diffstat (limited to 'internal/output')
-rw-r--r--internal/output/output.go222
1 files changed, 222 insertions, 0 deletions
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}