From 5b8893550130ad8ffe39a6523a11994757493691 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:47:15 -0800 Subject: 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 --- internal/output/output.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 internal/output/output.go (limited to 'internal/output') 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 @@ +// Package output provides JSON response types for ship v2. +// All commands output JSON by default. Human-readable output is opt-in via --pretty. +package output + +import ( + "encoding/json" + "fmt" + "os" +) + +// Response is the base interface for all output types +type Response interface { + IsError() bool +} + +// DeployResponse is returned on successful deploy +type DeployResponse struct { + Status string `json:"status"` + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` // "static", "docker", "binary" + TookMs int64 `json:"took_ms"` + Health *HealthResult `json:"health,omitempty"` + Expires string `json:"expires,omitempty"` // ISO 8601, only if TTL set +} + +func (r DeployResponse) IsError() bool { return false } + +// HealthResult is the health check outcome +type HealthResult struct { + Endpoint string `json:"endpoint"` + Status int `json:"status"` + LatencyMs int64 `json:"latency_ms"` +} + +// ListResponse is returned by ship list +type ListResponse struct { + Status string `json:"status"` + Deploys []DeployInfo `json:"deploys"` +} + +func (r ListResponse) IsError() bool { return false } + +// DeployInfo is a single deploy in a list +type DeployInfo struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Running bool `json:"running"` + Expires string `json:"expires,omitempty"` +} + +// StatusResponse is returned by ship status +type StatusResponse struct { + Status string `json:"status"` + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Running bool `json:"running"` + Port int `json:"port,omitempty"` + Expires string `json:"expires,omitempty"` + Memory string `json:"memory,omitempty"` + CPU string `json:"cpu,omitempty"` +} + +func (r StatusResponse) IsError() bool { return false } + +// LogsResponse is returned by ship logs +type LogsResponse struct { + Status string `json:"status"` + Name string `json:"name"` + Lines []string `json:"lines"` +} + +func (r LogsResponse) IsError() bool { return false } + +// RemoveResponse is returned by ship remove +type RemoveResponse struct { + Status string `json:"status"` + Name string `json:"name"` + Removed bool `json:"removed"` +} + +func (r RemoveResponse) IsError() bool { return false } + +// HostInitResponse is returned by ship host init +type HostInitResponse struct { + Status string `json:"status"` + Host string `json:"host"` + Domain string `json:"domain"` + Installed []string `json:"installed"` +} + +func (r HostInitResponse) IsError() bool { return false } + +// ErrorResponse is returned on any failure +type ErrorResponse struct { + Status string `json:"status"` // always "error" + Code string `json:"code"` + Message string `json:"message"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` +} + +func (r ErrorResponse) IsError() bool { return true } + +// Error codes +const ( + ErrInvalidPath = "INVALID_PATH" + ErrUnknownProjectType = "UNKNOWN_PROJECT_TYPE" + ErrSSHConnectFailed = "SSH_CONNECT_FAILED" + ErrSSHAuthFailed = "SSH_AUTH_FAILED" + ErrUploadFailed = "UPLOAD_FAILED" + ErrBuildFailed = "BUILD_FAILED" + ErrServiceFailed = "SERVICE_FAILED" + ErrCaddyFailed = "CADDY_FAILED" + ErrHealthCheckFailed = "HEALTH_CHECK_FAILED" + ErrHealthCheckTimeout = "HEALTH_CHECK_TIMEOUT" + ErrNotFound = "NOT_FOUND" + ErrConflict = "CONFLICT" + ErrHostNotConfigured = "HOST_NOT_CONFIGURED" + ErrInvalidTTL = "INVALID_TTL" + ErrInvalidName = "INVALID_NAME" + ErrPortExhausted = "PORT_EXHAUSTED" +) + +// Exit codes +const ( + ExitSuccess = 0 + ExitDeployFailed = 1 + ExitInvalidArgs = 2 + ExitSSHFailed = 3 + ExitHealthFailed = 4 +) + +// Pretty controls human-readable output +var Pretty bool + +// Print outputs the response as JSON (or pretty if enabled) +func Print(r Response) { + if Pretty { + printPretty(r) + return + } + enc := json.NewEncoder(os.Stdout) + enc.Encode(r) +} + +// PrintAndExit outputs the response and exits with appropriate code +func PrintAndExit(r Response) { + Print(r) + if r.IsError() { + os.Exit(exitCodeForError(r.(*ErrorResponse).Code)) + } + os.Exit(ExitSuccess) +} + +// Err creates an ErrorResponse +func Err(code, message string) *ErrorResponse { + return &ErrorResponse{ + Status: "error", + Code: code, + Message: message, + } +} + +// ErrWithName creates an ErrorResponse with name context +func ErrWithName(code, message, name string) *ErrorResponse { + return &ErrorResponse{ + Status: "error", + Code: code, + Message: message, + Name: name, + } +} + +func exitCodeForError(code string) int { + switch code { + case ErrSSHConnectFailed, ErrSSHAuthFailed: + return ExitSSHFailed + case ErrHealthCheckFailed, ErrHealthCheckTimeout: + return ExitHealthFailed + case ErrInvalidPath, ErrInvalidTTL, ErrInvalidName, ErrHostNotConfigured: + return ExitInvalidArgs + default: + return ExitDeployFailed + } +} + +func printPretty(r Response) { + switch v := r.(type) { + case *DeployResponse: + fmt.Printf("✓ Deployed to %s (%.1fs)\n", v.URL, float64(v.TookMs)/1000) + case *ListResponse: + if len(v.Deploys) == 0 { + fmt.Println("No deployments") + return + } + fmt.Printf("%-20s %-40s %-8s %s\n", "NAME", "URL", "TYPE", "STATUS") + for _, d := range v.Deploys { + status := "running" + if !d.Running { + status = "stopped" + } + if d.Expires != "" { + status += " (expires " + d.Expires + ")" + } + fmt.Printf("%-20s %-40s %-8s %s\n", d.Name, d.URL, d.Type, status) + } + case *RemoveResponse: + fmt.Printf("✓ Removed %s\n", v.Name) + case *ErrorResponse: + fmt.Printf("✗ %s: %s\n", v.Code, v.Message) + case *HostInitResponse: + fmt.Printf("✓ Initialized %s with domain %s\n", v.Host, v.Domain) + default: + // Fallback to JSON + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(r) + } +} -- cgit v1.2.3