aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/detect/detect.go105
-rw-r--r--internal/output/output.go226
-rw-r--r--internal/ssh/client.go393
-rw-r--r--internal/state/state.go106
-rw-r--r--internal/templates/templates.go358
5 files changed, 0 insertions, 1188 deletions
diff --git a/internal/detect/detect.go b/internal/detect/detect.go
deleted file mode 100644
index f3efd22..0000000
--- a/internal/detect/detect.go
+++ /dev/null
@@ -1,105 +0,0 @@
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
deleted file mode 100644
index a9a1036..0000000
--- a/internal/output/output.go
+++ /dev/null
@@ -1,226 +0,0 @@
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/ssh/client.go b/internal/ssh/client.go
deleted file mode 100644
index b9c8d0f..0000000
--- a/internal/ssh/client.go
+++ /dev/null
@@ -1,393 +0,0 @@
1package ssh
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14 "golang.org/x/crypto/ssh/agent"
15)
16
17// Client represents an SSH connection to a remote host
18type Client struct {
19 host string
20 client *ssh.Client
21}
22
23// sshConfig holds SSH configuration for a host
24type sshConfig struct {
25 Host string
26 HostName string
27 User string
28 Port string
29 IdentityFile string
30}
31
32// Connect establishes an SSH connection to the remote host
33// Supports both SSH config aliases (e.g., "myserver") and user@host format
34func Connect(host string) (*Client, error) {
35 var user, addr string
36 var identityFile string
37
38 // Try to read SSH config first
39 cfg, err := readSSHConfig(host)
40 if err == nil && cfg.HostName != "" {
41 // Use SSH config
42 user = cfg.User
43 addr = cfg.HostName
44 if cfg.Port != "" {
45 addr = addr + ":" + cfg.Port
46 } else {
47 addr = addr + ":22"
48 }
49 identityFile = cfg.IdentityFile
50 } else {
51 // Fall back to parsing user@host format
52 parts := strings.SplitN(host, "@", 2)
53 if len(parts) != 2 {
54 return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host)
55 }
56 user = parts[0]
57 addr = parts[1]
58
59 // Add default port if not specified
60 if !strings.Contains(addr, ":") {
61 addr = addr + ":22"
62 }
63 }
64
65 // Build authentication methods
66 var authMethods []ssh.AuthMethod
67
68 // Try identity file from SSH config first
69 if identityFile != "" {
70 if authMethod, err := publicKeyFromFile(identityFile); err == nil {
71 authMethods = append(authMethods, authMethod)
72 }
73 }
74
75 // Try SSH agent
76 if authMethod, err := sshAgent(); err == nil {
77 authMethods = append(authMethods, authMethod)
78 }
79
80 // Try default key files
81 if authMethod, err := publicKeyFile(); err == nil {
82 authMethods = append(authMethods, authMethod)
83 }
84
85 if len(authMethods) == 0 {
86 return nil, fmt.Errorf("no SSH authentication method available")
87 }
88
89 config := &ssh.ClientConfig{
90 User: user,
91 Auth: authMethods,
92 HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts
93 }
94
95 client, err := ssh.Dial("tcp", addr, config)
96 if err != nil {
97 return nil, fmt.Errorf("failed to connect to %s: %w", host, err)
98 }
99
100 return &Client{
101 host: host,
102 client: client,
103 }, nil
104}
105
106// Close closes the SSH connection
107func (c *Client) Close() error {
108 return c.client.Close()
109}
110
111// Run executes a command on the remote host and returns the output
112func (c *Client) Run(cmd string) (string, error) {
113 session, err := c.client.NewSession()
114 if err != nil {
115 return "", err
116 }
117 defer session.Close()
118
119 var stdout, stderr bytes.Buffer
120 session.Stdout = &stdout
121 session.Stderr = &stderr
122
123 if err := session.Run(cmd); err != nil {
124 return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String())
125 }
126
127 return stdout.String(), nil
128}
129
130// RunSudo executes a command with sudo on the remote host
131func (c *Client) RunSudo(cmd string) (string, error) {
132 return c.Run("sudo " + cmd)
133}
134
135// RunSudoStream executes a command with sudo and streams output to stdout/stderr
136func (c *Client) RunSudoStream(cmd string) error {
137 session, err := c.client.NewSession()
138 if err != nil {
139 return err
140 }
141 defer session.Close()
142
143 session.Stdout = os.Stdout
144 session.Stderr = os.Stderr
145
146 if err := session.Run("sudo " + cmd); err != nil {
147 return fmt.Errorf("command failed: %w", err)
148 }
149
150 return nil
151}
152
153// RunStream executes a command and streams output to stdout/stderr
154func (c *Client) RunStream(cmd string) error {
155 session, err := c.client.NewSession()
156 if err != nil {
157 return err
158 }
159 defer session.Close()
160
161 session.Stdout = os.Stdout
162 session.Stderr = os.Stderr
163
164 if err := session.Run(cmd); err != nil {
165 return fmt.Errorf("command failed: %w", err)
166 }
167
168 return nil
169}
170
171// Upload copies a local file to the remote host using scp
172func (c *Client) Upload(localPath, remotePath string) error {
173 // Use external scp command for simplicity
174 // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath
175 cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath)
176
177 var stderr bytes.Buffer
178 cmd.Stderr = &stderr
179
180 if err := cmd.Run(); err != nil {
181 return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String())
182 }
183
184 return nil
185}
186
187// UploadDir copies a local directory to the remote host using rsync
188func (c *Client) UploadDir(localDir, remoteDir string) error {
189 // Use rsync for directory uploads
190 // Format: rsync -avz --delete localDir/ user@host:remoteDir/
191 localDir = strings.TrimSuffix(localDir, "/") + "/"
192 remoteDir = strings.TrimSuffix(remoteDir, "/") + "/"
193
194 cmd := exec.Command("rsync", "-avz", "--delete",
195 "-e", "ssh -o StrictHostKeyChecking=no",
196 localDir, c.host+":"+remoteDir)
197
198 var stderr bytes.Buffer
199 cmd.Stderr = &stderr
200
201 if err := cmd.Run(); err != nil {
202 return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String())
203 }
204
205 return nil
206}
207
208// WriteFile creates a file with the given content on the remote host
209func (c *Client) WriteFile(remotePath, content string) error {
210 session, err := c.client.NewSession()
211 if err != nil {
212 return err
213 }
214 defer session.Close()
215
216 // Use cat to write content to file
217 cmd := fmt.Sprintf("cat > %s", remotePath)
218 session.Stdin = strings.NewReader(content)
219
220 var stderr bytes.Buffer
221 session.Stderr = &stderr
222
223 if err := session.Run(cmd); err != nil {
224 return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String())
225 }
226
227 return nil
228}
229
230// WriteSudoFile creates a file with the given content using sudo
231func (c *Client) WriteSudoFile(remotePath, content string) error {
232 session, err := c.client.NewSession()
233 if err != nil {
234 return err
235 }
236 defer session.Close()
237
238 // Use sudo tee to write content to file
239 cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath)
240 session.Stdin = strings.NewReader(content)
241
242 var stderr bytes.Buffer
243 session.Stderr = &stderr
244
245 if err := session.Run(cmd); err != nil {
246 return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String())
247 }
248
249 return nil
250}
251
252// readSSHConfig reads and parses the SSH config file for a given host
253func readSSHConfig(host string) (*sshConfig, error) {
254 home, err := os.UserHomeDir()
255 if err != nil {
256 return nil, err
257 }
258
259 configPath := filepath.Join(home, ".ssh", "config")
260 file, err := os.Open(configPath)
261 if err != nil {
262 return nil, err
263 }
264 defer file.Close()
265
266 cfg := &sshConfig{}
267 var currentHost string
268 var matchedHost bool
269
270 scanner := bufio.NewScanner(file)
271 for scanner.Scan() {
272 line := strings.TrimSpace(scanner.Text())
273
274 // Skip comments and empty lines
275 if line == "" || strings.HasPrefix(line, "#") {
276 continue
277 }
278
279 fields := strings.Fields(line)
280 if len(fields) < 2 {
281 continue
282 }
283
284 key := strings.ToLower(fields[0])
285 value := fields[1]
286
287 // Expand ~ in paths
288 if strings.HasPrefix(value, "~/") {
289 value = filepath.Join(home, value[2:])
290 }
291
292 switch key {
293 case "host":
294 currentHost = value
295 if currentHost == host {
296 matchedHost = true
297 cfg.Host = host
298 } else {
299 matchedHost = false
300 }
301 case "hostname":
302 if matchedHost {
303 cfg.HostName = value
304 }
305 case "user":
306 if matchedHost {
307 cfg.User = value
308 }
309 case "port":
310 if matchedHost {
311 cfg.Port = value
312 }
313 case "identityfile":
314 if matchedHost {
315 cfg.IdentityFile = value
316 }
317 }
318 }
319
320 if err := scanner.Err(); err != nil {
321 return nil, err
322 }
323
324 if cfg.Host == "" {
325 return nil, fmt.Errorf("host %s not found in SSH config", host)
326 }
327
328 return cfg, nil
329}
330
331// sshAgent returns an auth method using SSH agent
332func sshAgent() (ssh.AuthMethod, error) {
333 socket := os.Getenv("SSH_AUTH_SOCK")
334 if socket == "" {
335 return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
336 }
337
338 conn, err := net.Dial("unix", socket)
339 if err != nil {
340 return nil, fmt.Errorf("failed to connect to SSH agent: %w", err)
341 }
342
343 agentClient := agent.NewClient(conn)
344 return ssh.PublicKeysCallback(agentClient.Signers), nil
345}
346
347// publicKeyFromFile returns an auth method from a specific private key file
348func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) {
349 key, err := os.ReadFile(keyPath)
350 if err != nil {
351 return nil, err
352 }
353
354 signer, err := ssh.ParsePrivateKey(key)
355 if err != nil {
356 return nil, err
357 }
358
359 return ssh.PublicKeys(signer), nil
360}
361
362// publicKeyFile returns an auth method using a private key file
363func publicKeyFile() (ssh.AuthMethod, error) {
364 home, err := os.UserHomeDir()
365 if err != nil {
366 return nil, err
367 }
368
369 // Try common key locations
370 keyPaths := []string{
371 filepath.Join(home, ".ssh", "id_rsa"),
372 filepath.Join(home, ".ssh", "id_ed25519"),
373 filepath.Join(home, ".ssh", "id_ecdsa"),
374 }
375
376 for _, keyPath := range keyPaths {
377 if _, err := os.Stat(keyPath); err == nil {
378 key, err := os.ReadFile(keyPath)
379 if err != nil {
380 continue
381 }
382
383 signer, err := ssh.ParsePrivateKey(key)
384 if err != nil {
385 continue
386 }
387
388 return ssh.PublicKeys(signer), nil
389 }
390 }
391
392 return nil, fmt.Errorf("no SSH private key found")
393}
diff --git a/internal/state/state.go b/internal/state/state.go
deleted file mode 100644
index 9b06179..0000000
--- a/internal/state/state.go
+++ /dev/null
@@ -1,106 +0,0 @@
1package state
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "regexp"
9)
10
11// State represents the local ship configuration
12type State struct {
13 DefaultHost string `json:"default_host,omitempty"`
14 Hosts map[string]*Host `json:"hosts"`
15}
16
17// Host represents configuration for a single VPS
18type Host struct {
19 BaseDomain string `json:"base_domain,omitempty"`
20 GitSetup bool `json:"git_setup,omitempty"`
21}
22
23var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
24
25// ValidateName checks that a name is safe for use in shell commands,
26// file paths, systemd units, and DNS labels.
27func ValidateName(name string) error {
28 if !validName.MatchString(name) {
29 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)
30 }
31 return nil
32}
33
34// Load reads state from ~/.config/ship/state.json
35func Load() (*State, error) {
36 path := statePath()
37
38 if _, err := os.Stat(path); os.IsNotExist(err) {
39 return &State{
40 Hosts: make(map[string]*Host),
41 }, nil
42 }
43
44 data, err := os.ReadFile(path)
45 if err != nil {
46 return nil, fmt.Errorf("failed to read state file: %w", err)
47 }
48
49 var state State
50 if err := json.Unmarshal(data, &state); err != nil {
51 return nil, fmt.Errorf("failed to parse state file: %w", err)
52 }
53
54 if state.Hosts == nil {
55 state.Hosts = make(map[string]*Host)
56 }
57
58 return &state, nil
59}
60
61// Save writes state to ~/.config/ship/state.json
62func (s *State) Save() error {
63 path := statePath()
64
65 dir := filepath.Dir(path)
66 if err := os.MkdirAll(dir, 0755); err != nil {
67 return fmt.Errorf("failed to create config directory: %w", err)
68 }
69
70 data, err := json.MarshalIndent(s, "", " ")
71 if err != nil {
72 return fmt.Errorf("failed to marshal state: %w", err)
73 }
74
75 if err := os.WriteFile(path, data, 0600); err != nil {
76 return fmt.Errorf("failed to write state file: %w", err)
77 }
78
79 return nil
80}
81
82// GetHost returns the host config, creating it if it doesn't exist
83func (s *State) GetHost(host string) *Host {
84 if s.Hosts[host] == nil {
85 s.Hosts[host] = &Host{}
86 }
87 return s.Hosts[host]
88}
89
90// GetDefaultHost returns the default host, or empty string if not set
91func (s *State) GetDefaultHost() string {
92 return s.DefaultHost
93}
94
95// SetDefaultHost sets the default host
96func (s *State) SetDefaultHost(host string) {
97 s.DefaultHost = host
98}
99
100func statePath() string {
101 home, err := os.UserHomeDir()
102 if err != nil {
103 return ".ship-state.json"
104 }
105 return filepath.Join(home, ".config", "ship", "state.json")
106}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
deleted file mode 100644
index 2163f47..0000000
--- a/internal/templates/templates.go
+++ /dev/null
@@ -1,358 +0,0 @@
1package templates
2
3import (
4 "bytes"
5 "text/template"
6)
7
8var serviceTemplate = `[Unit]
9Description={{.Name}}
10After=network.target
11
12[Service]
13Type=simple
14User={{.User}}
15WorkingDirectory={{.WorkDir}}
16EnvironmentFile={{.EnvFile}}
17ExecStart={{.BinaryPath}} {{.Args}}
18Restart=always
19RestartSec=5s
20NoNewPrivileges=true
21PrivateTmp=true
22{{- if .Memory}}
23MemoryMax={{.Memory}}
24{{- end}}
25{{- if .CPU}}
26CPUQuota={{.CPU}}
27{{- end}}
28
29[Install]
30WantedBy=multi-user.target
31`
32
33var appCaddyTemplate = `{{.Domain}} {
34 reverse_proxy 127.0.0.1:{{.Port}}
35}
36`
37
38var staticCaddyTemplate = `{{.Domain}} {
39 root * {{.RootDir}}
40 file_server
41 encode gzip
42}
43`
44
45// SystemdService generates a systemd service unit file
46func SystemdService(data map[string]string) (string, error) {
47 tmpl, err := template.New("service").Parse(serviceTemplate)
48 if err != nil {
49 return "", err
50 }
51
52 var buf bytes.Buffer
53 if err := tmpl.Execute(&buf, data); err != nil {
54 return "", err
55 }
56
57 return buf.String(), nil
58}
59
60// AppCaddy generates a Caddy config for a Go app
61func AppCaddy(data map[string]string) (string, error) {
62 tmpl, err := template.New("caddy").Parse(appCaddyTemplate)
63 if err != nil {
64 return "", err
65 }
66
67 var buf bytes.Buffer
68 if err := tmpl.Execute(&buf, data); err != nil {
69 return "", err
70 }
71
72 return buf.String(), nil
73}
74
75// StaticCaddy generates a Caddy config for a static site
76func StaticCaddy(data map[string]string) (string, error) {
77 tmpl, err := template.New("caddy").Parse(staticCaddyTemplate)
78 if err != nil {
79 return "", err
80 }
81
82 var buf bytes.Buffer
83 if err := tmpl.Execute(&buf, data); err != nil {
84 return "", err
85 }
86
87 return buf.String(), nil
88}
89
90var postReceiveHookTemplate = `#!/bin/bash
91set -euo pipefail
92
93REPO=/srv/git/{{.Name}}.git
94SRC=/var/lib/{{.Name}}/src
95NAME={{.Name}}
96
97while read oldrev newrev refname; do
98 branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
99 [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
100done
101
102# Ensure checkout directory exists
103sudo /bin/mkdir -p "$SRC"
104sudo /bin/chown -R git:git "/var/lib/${NAME}"
105
106echo "==> Checking out code..."
107git --work-tree="$SRC" --git-dir="$REPO" checkout -f main
108
109cd "$SRC"
110
111# If no Dockerfile, nothing to deploy
112if [ ! -f Dockerfile ]; then
113 echo "No Dockerfile found, skipping deploy."
114 exit 0
115fi
116
117# Install deployment config from repo (using full paths for sudoers)
118if [ -f "$SRC/.ship/service" ]; then
119 echo "==> Installing systemd unit..."
120 sudo /bin/cp "$SRC/.ship/service" "/etc/systemd/system/${NAME}.service"
121 sudo systemctl daemon-reload
122fi
123if [ -f "$SRC/.ship/Caddyfile" ]; then
124 echo "==> Installing Caddy config..."
125 sudo /bin/cp "$SRC/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy"
126 sudo systemctl reload caddy
127fi
128
129# Ensure data directory exists
130sudo /bin/mkdir -p "/var/lib/${NAME}/data"
131sudo /bin/chown -R git:git "/var/lib/${NAME}/data"
132
133echo "==> Building Docker image..."
134docker build -t ${NAME}:latest .
135
136echo "==> Restarting service..."
137sudo systemctl restart ${NAME}
138
139echo "==> Deploy complete!"
140`
141
142var postReceiveHookStaticTemplate = `#!/bin/bash
143set -euo pipefail
144
145REPO=/srv/git/{{.Name}}.git
146WEBROOT=/var/www/{{.Name}}
147NAME={{.Name}}
148
149while read oldrev newrev refname; do
150 branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
151 [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
152done
153
154echo "==> Deploying static site..."
155git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main
156
157if [ -f "$WEBROOT/.ship/Caddyfile" ]; then
158 echo "==> Installing Caddy config..."
159 sudo /bin/cp "$WEBROOT/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy"
160 sudo systemctl reload caddy
161fi
162
163echo "==> Deploy complete!"
164`
165
166var codeCaddyTemplate = `{{.BaseDomain}} {
167 @goget query go-get=1
168 handle @goget {
169 root * /opt/ship/vanity
170 templates
171 rewrite * /index.html
172 file_server
173 }
174
175 @git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$"
176 handle @git {
177 reverse_proxy unix//run/fcgiwrap.socket {
178 transport fastcgi {
179 env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend
180 env GIT_PROJECT_ROOT /srv/git
181 env REQUEST_METHOD {method}
182 env QUERY_STRING {query}
183 env PATH_INFO {path}
184 }
185 }
186 }
187
188 @cgitassets path /cgit/*
189 handle @cgitassets {
190 root * /usr/share/cgit
191 uri strip_prefix /cgit
192 file_server
193 }
194
195 handle {
196 reverse_proxy unix//run/fcgiwrap.socket {
197 transport fastcgi {
198 env SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi
199 env QUERY_STRING {query}
200 env REQUEST_METHOD {method}
201 env PATH_INFO {path}
202 env HTTP_HOST {host}
203 env SERVER_NAME {host}
204 }
205 }
206 }
207}
208`
209
210var dockerServiceTemplate = `[Unit]
211Description={{.Name}}
212After=network.target docker.service
213Requires=docker.service
214
215[Service]
216Type=simple
217ExecStartPre=-/usr/bin/docker rm -f {{.Name}}
218ExecStart=/usr/bin/docker run --rm --name {{.Name}} \
219 -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \
220 --env-file /etc/ship/env/{{.Name}}.env \
221 -v /var/lib/{{.Name}}/data:/data \
222 {{.Name}}:latest
223ExecStop=/usr/bin/docker stop -t 10 {{.Name}}
224Restart=always
225RestartSec=5s
226
227[Install]
228WantedBy=multi-user.target
229`
230
231var defaultAppCaddyTemplate = `{{.Domain}} {
232 reverse_proxy 127.0.0.1:{{.Port}}
233}
234`
235
236var defaultStaticCaddyTemplate = `{{.Domain}} {
237 root * /var/www/{{.Name}}
238 file_server
239 encode gzip
240}
241`
242
243// PostReceiveHook generates a post-receive hook for git-app repos
244func PostReceiveHook(data map[string]string) (string, error) {
245 return renderTemplate("post-receive", postReceiveHookTemplate, data)
246}
247
248// PostReceiveHookStatic generates a post-receive hook for git-static repos
249func PostReceiveHookStatic(data map[string]string) (string, error) {
250 return renderTemplate("post-receive-static", postReceiveHookStaticTemplate, data)
251}
252
253// CodeCaddy generates the base domain Caddy config for vanity imports + git HTTP
254func CodeCaddy(data map[string]string) (string, error) {
255 return renderTemplate("code-caddy", codeCaddyTemplate, data)
256}
257
258var cgitrcTemplate = `virtual-root=/
259css=/cgit/cgit.css
260logo=/cgit/cgit.png
261header=/opt/ship/cgit-header.html
262scan-path=/srv/git/
263export-ok=git-daemon-export-ok
264enable-http-clone=0
265clone-url=https://{{.BaseDomain}}/$CGIT_REPO_URL
266root-title={{.BaseDomain}}
267root-desc=
268remove-suffix=.git
269`
270
271var cgitHeaderTemplate = `<style>
272body, table, td, th, div#cgit { background: #1a1a2e; color: #ccc; }
273a { color: #7aa2f7; }
274a:hover { color: #9ecbff; }
275table.list tr:hover td { background: #222244; }
276table.list td, table.list th { border-bottom: 1px solid #333; }
277th { background: #16213e; }
278td.commitgraph .column1 { color: #7aa2f7; }
279td.commitgraph .column2 { color: #9ece6a; }
280td.logheader { background: #16213e; }
281div#header { background: #16213e; border-bottom: 1px solid #333; }
282div#header .sub { color: #888; }
283table.tabs { border-bottom: 1px solid #333; }
284table.tabs td a { color: #ccc; }
285table.tabs td a.active { color: #fff; background: #1a1a2e; border: 1px solid #333; border-bottom: 1px solid #1a1a2e; }
286div.footer { color: #555; }
287div.footer a { color: #555; }
288div.diffstat-header { background: #16213e; }
289table.diffstat { border-bottom: 1px solid #333; }
290table.diffstat td.graph span.graph-moreremoved { background: #f7768e; }
291table.diffstat td.graph span.graph-moreadded { background: #9ece6a; }
292table.diffstat td.graph span.graph-removed { background: #f7768e; }
293table.diffstat td.graph span.graph-added { background: #9ece6a; }
294table.diff { background: #131320; border: 1px solid #333; }
295div.diff td { font-family: monospace; }
296div.head { color: #ccc; background: #16213e; padding: 2px 4px; }
297div.hunk { color: #7aa2f7; background: #1a1a3e; padding: 2px 4px; }
298div.add { color: #9ece6a; background: #1a2e1a; padding: 2px 4px; }
299div.del { color: #f7768e; background: #2e1a1a; padding: 2px 4px; }
300table.diff td.add { color: #9ece6a; background: #1a2e1a; }
301table.diff td.del { color: #f7768e; background: #2e1a1a; }
302table.diff td.hunk { color: #7aa2f7; background: #1a1a3e; }
303table.diff td { border: none; background: #1a1a2e; }
304table.blob td.lines { color: #ccc; }
305table.blob td.linenumbers { background: #16213e; }
306table.blob td.linenumbers a { color: #555; }
307table.blob td.linenumbers a:hover { color: #7aa2f7; }
308table.ssdiff td.add { color: #9ece6a; background: #1a2e1a; }
309table.ssdiff td.del { color: #f7768e; background: #2e1a1a; }
310table.ssdiff td { border-right: 1px solid #333; }
311table.ssdiff td.hunk { color: #7aa2f7; background: #1a1a3e; }
312table.ssdiff td.head { background: #16213e; border-bottom: 1px solid #333; }
313table.ssdiff td.foot { background: #16213e; border-top: 1px solid #333; }
314table.ssdiff td.lineno { background: #16213e; color: #555; }
315pre { color: #ccc; }
316input, textarea, select { background: #222; color: #ccc; border: 1px solid #444; }
317img#logo { display: none; }
318</style>
319`
320
321// CgitRC generates the /etc/cgitrc config file
322func CgitRC(data map[string]string) (string, error) {
323 return renderTemplate("cgitrc", cgitrcTemplate, data)
324}
325
326// CgitHeader generates the cgit header HTML file (dark theme)
327func CgitHeader() string {
328 return cgitHeaderTemplate
329}
330
331// DockerService generates a systemd unit for a Docker-based app
332func DockerService(data map[string]string) (string, error) {
333 return renderTemplate("docker-service", dockerServiceTemplate, data)
334}
335
336// DefaultAppCaddy generates a default Caddyfile for a git-app
337func DefaultAppCaddy(data map[string]string) (string, error) {
338 return renderTemplate("default-app-caddy", defaultAppCaddyTemplate, data)
339}
340
341// DefaultStaticCaddy generates a default Caddyfile for a git-static site
342func DefaultStaticCaddy(data map[string]string) (string, error) {
343 return renderTemplate("default-static-caddy", defaultStaticCaddyTemplate, data)
344}
345
346func renderTemplate(name, tmplStr string, data map[string]string) (string, error) {
347 tmpl, err := template.New(name).Parse(tmplStr)
348 if err != nil {
349 return "", err
350 }
351
352 var buf bytes.Buffer
353 if err := tmpl.Execute(&buf, data); err != nil {
354 return "", err
355 }
356
357 return buf.String(), nil
358}