From 778bef5ee6941056e06326d1eaaa6956d7307a85 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sat, 18 Apr 2026 14:40:17 -0700 Subject: Remove Go implementation — ship is skills-only now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skills/ directory fully replaces the old Go CLI. Drop all Go source, build files, planning docs, and the stale SECURITY.md (which described the old git-user push-deploy model that no longer exists). Trim .gitignore to match the new tree. --- internal/detect/detect.go | 105 ----------- internal/output/output.go | 226 ----------------------- internal/ssh/client.go | 393 ---------------------------------------- internal/state/state.go | 106 ----------- internal/templates/templates.go | 358 ------------------------------------ 5 files changed, 1188 deletions(-) delete mode 100644 internal/detect/detect.go delete mode 100644 internal/output/output.go delete mode 100644 internal/ssh/client.go delete mode 100644 internal/state/state.go delete mode 100644 internal/templates/templates.go (limited to 'internal') 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 @@ -// Package detect provides auto-detection of project types. -package detect - -import ( - "os" - "path/filepath" - - "github.com/bdw/ship/internal/output" -) - -// ProjectType represents the detected deployment type -type ProjectType string - -const ( - TypeStatic ProjectType = "static" - TypeDocker ProjectType = "docker" - TypeBinary ProjectType = "binary" -) - -// Result is the detection outcome -type Result struct { - Type ProjectType - Path string // Absolute path to deploy - Error *output.ErrorResponse -} - -// Detect examines a path and determines how to deploy it. -// Follows the logic from SPEC.md: -// - File: must be executable → binary -// - Directory with Dockerfile → docker -// - Directory with index.html → static -// - Go/Node without Dockerfile → error with guidance -// - Empty or unknown → error -func Detect(path string) Result { - absPath, err := filepath.Abs(path) - if err != nil { - return Result{Error: output.Err(output.ErrInvalidPath, "cannot resolve path: "+err.Error())} - } - - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { - return Result{Error: output.Err(output.ErrInvalidPath, "path does not exist: "+path)} - } - return Result{Error: output.Err(output.ErrInvalidPath, "cannot access path: "+err.Error())} - } - - // File: must be executable binary - if !info.IsDir() { - return detectFile(absPath, info) - } - - // Directory: check contents - return detectDirectory(absPath) -} - -func detectFile(path string, info os.FileInfo) Result { - // Check if executable - if info.Mode()&0111 == 0 { - return Result{Error: output.Err(output.ErrUnknownProjectType, "file is not executable")} - } - return Result{Type: TypeBinary, Path: path} -} - -func detectDirectory(path string) Result { - // Check for Dockerfile first (highest priority) - if hasFile(path, "Dockerfile") { - return Result{Type: TypeDocker, Path: path} - } - - // Check for static site - if hasFile(path, "index.html") || hasFile(path, "index.htm") { - return Result{Type: TypeStatic, Path: path} - } - - // Check for Go project without Dockerfile - if hasFile(path, "go.mod") { - return Result{Error: output.Err(output.ErrUnknownProjectType, - "Go project without Dockerfile. Add a Dockerfile or build a binary first.")} - } - - // Check for Node project without Dockerfile - if hasFile(path, "package.json") { - return Result{Error: output.Err(output.ErrUnknownProjectType, - "Node project without Dockerfile. Add a Dockerfile.")} - } - - // Check if empty - entries, err := os.ReadDir(path) - if err != nil { - return Result{Error: output.Err(output.ErrInvalidPath, "cannot read directory: "+err.Error())} - } - if len(entries) == 0 { - return Result{Error: output.Err(output.ErrInvalidPath, "directory is empty")} - } - - // Unknown - return Result{Error: output.Err(output.ErrUnknownProjectType, - "cannot detect project type. Add a Dockerfile or index.html.")} -} - -func hasFile(dir, name string) bool { - _, err := os.Stat(filepath.Join(dir, name)) - return err == nil -} 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 @@ -// 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 implements the error interface for compatibility with v1 code -func (r *ErrorResponse) Error() string { return r.Message } - -// Error codes -const ( - ErrInvalidPath = "INVALID_PATH" - ErrInvalidArgs = "INVALID_ARGS" - 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, ErrInvalidArgs: - 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) - } -} 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 @@ -package ssh - -import ( - "bufio" - "bytes" - "fmt" - "net" - "os" - "os/exec" - "path/filepath" - "strings" - - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" -) - -// Client represents an SSH connection to a remote host -type Client struct { - host string - client *ssh.Client -} - -// sshConfig holds SSH configuration for a host -type sshConfig struct { - Host string - HostName string - User string - Port string - IdentityFile string -} - -// Connect establishes an SSH connection to the remote host -// Supports both SSH config aliases (e.g., "myserver") and user@host format -func Connect(host string) (*Client, error) { - var user, addr string - var identityFile string - - // Try to read SSH config first - cfg, err := readSSHConfig(host) - if err == nil && cfg.HostName != "" { - // Use SSH config - user = cfg.User - addr = cfg.HostName - if cfg.Port != "" { - addr = addr + ":" + cfg.Port - } else { - addr = addr + ":22" - } - identityFile = cfg.IdentityFile - } else { - // Fall back to parsing user@host format - parts := strings.SplitN(host, "@", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host) - } - user = parts[0] - addr = parts[1] - - // Add default port if not specified - if !strings.Contains(addr, ":") { - addr = addr + ":22" - } - } - - // Build authentication methods - var authMethods []ssh.AuthMethod - - // Try identity file from SSH config first - if identityFile != "" { - if authMethod, err := publicKeyFromFile(identityFile); err == nil { - authMethods = append(authMethods, authMethod) - } - } - - // Try SSH agent - if authMethod, err := sshAgent(); err == nil { - authMethods = append(authMethods, authMethod) - } - - // Try default key files - if authMethod, err := publicKeyFile(); err == nil { - authMethods = append(authMethods, authMethod) - } - - if len(authMethods) == 0 { - return nil, fmt.Errorf("no SSH authentication method available") - } - - config := &ssh.ClientConfig{ - User: user, - Auth: authMethods, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts - } - - client, err := ssh.Dial("tcp", addr, config) - if err != nil { - return nil, fmt.Errorf("failed to connect to %s: %w", host, err) - } - - return &Client{ - host: host, - client: client, - }, nil -} - -// Close closes the SSH connection -func (c *Client) Close() error { - return c.client.Close() -} - -// Run executes a command on the remote host and returns the output -func (c *Client) Run(cmd string) (string, error) { - session, err := c.client.NewSession() - if err != nil { - return "", err - } - defer session.Close() - - var stdout, stderr bytes.Buffer - session.Stdout = &stdout - session.Stderr = &stderr - - if err := session.Run(cmd); err != nil { - return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String()) - } - - return stdout.String(), nil -} - -// RunSudo executes a command with sudo on the remote host -func (c *Client) RunSudo(cmd string) (string, error) { - return c.Run("sudo " + cmd) -} - -// RunSudoStream executes a command with sudo and streams output to stdout/stderr -func (c *Client) RunSudoStream(cmd string) error { - session, err := c.client.NewSession() - if err != nil { - return err - } - defer session.Close() - - session.Stdout = os.Stdout - session.Stderr = os.Stderr - - if err := session.Run("sudo " + cmd); err != nil { - return fmt.Errorf("command failed: %w", err) - } - - return nil -} - -// RunStream executes a command and streams output to stdout/stderr -func (c *Client) RunStream(cmd string) error { - session, err := c.client.NewSession() - if err != nil { - return err - } - defer session.Close() - - session.Stdout = os.Stdout - session.Stderr = os.Stderr - - if err := session.Run(cmd); err != nil { - return fmt.Errorf("command failed: %w", err) - } - - return nil -} - -// Upload copies a local file to the remote host using scp -func (c *Client) Upload(localPath, remotePath string) error { - // Use external scp command for simplicity - // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath - cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String()) - } - - return nil -} - -// UploadDir copies a local directory to the remote host using rsync -func (c *Client) UploadDir(localDir, remoteDir string) error { - // Use rsync for directory uploads - // Format: rsync -avz --delete localDir/ user@host:remoteDir/ - localDir = strings.TrimSuffix(localDir, "/") + "/" - remoteDir = strings.TrimSuffix(remoteDir, "/") + "/" - - cmd := exec.Command("rsync", "-avz", "--delete", - "-e", "ssh -o StrictHostKeyChecking=no", - localDir, c.host+":"+remoteDir) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String()) - } - - return nil -} - -// WriteFile creates a file with the given content on the remote host -func (c *Client) WriteFile(remotePath, content string) error { - session, err := c.client.NewSession() - if err != nil { - return err - } - defer session.Close() - - // Use cat to write content to file - cmd := fmt.Sprintf("cat > %s", remotePath) - session.Stdin = strings.NewReader(content) - - var stderr bytes.Buffer - session.Stderr = &stderr - - if err := session.Run(cmd); err != nil { - return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String()) - } - - return nil -} - -// WriteSudoFile creates a file with the given content using sudo -func (c *Client) WriteSudoFile(remotePath, content string) error { - session, err := c.client.NewSession() - if err != nil { - return err - } - defer session.Close() - - // Use sudo tee to write content to file - cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath) - session.Stdin = strings.NewReader(content) - - var stderr bytes.Buffer - session.Stderr = &stderr - - if err := session.Run(cmd); err != nil { - return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String()) - } - - return nil -} - -// readSSHConfig reads and parses the SSH config file for a given host -func readSSHConfig(host string) (*sshConfig, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - - configPath := filepath.Join(home, ".ssh", "config") - file, err := os.Open(configPath) - if err != nil { - return nil, err - } - defer file.Close() - - cfg := &sshConfig{} - var currentHost string - var matchedHost bool - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Skip comments and empty lines - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - - key := strings.ToLower(fields[0]) - value := fields[1] - - // Expand ~ in paths - if strings.HasPrefix(value, "~/") { - value = filepath.Join(home, value[2:]) - } - - switch key { - case "host": - currentHost = value - if currentHost == host { - matchedHost = true - cfg.Host = host - } else { - matchedHost = false - } - case "hostname": - if matchedHost { - cfg.HostName = value - } - case "user": - if matchedHost { - cfg.User = value - } - case "port": - if matchedHost { - cfg.Port = value - } - case "identityfile": - if matchedHost { - cfg.IdentityFile = value - } - } - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - if cfg.Host == "" { - return nil, fmt.Errorf("host %s not found in SSH config", host) - } - - return cfg, nil -} - -// sshAgent returns an auth method using SSH agent -func sshAgent() (ssh.AuthMethod, error) { - socket := os.Getenv("SSH_AUTH_SOCK") - if socket == "" { - return nil, fmt.Errorf("SSH_AUTH_SOCK not set") - } - - conn, err := net.Dial("unix", socket) - if err != nil { - return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) - } - - agentClient := agent.NewClient(conn) - return ssh.PublicKeysCallback(agentClient.Signers), nil -} - -// publicKeyFromFile returns an auth method from a specific private key file -func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) { - key, err := os.ReadFile(keyPath) - if err != nil { - return nil, err - } - - signer, err := ssh.ParsePrivateKey(key) - if err != nil { - return nil, err - } - - return ssh.PublicKeys(signer), nil -} - -// publicKeyFile returns an auth method using a private key file -func publicKeyFile() (ssh.AuthMethod, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - - // Try common key locations - keyPaths := []string{ - filepath.Join(home, ".ssh", "id_rsa"), - filepath.Join(home, ".ssh", "id_ed25519"), - filepath.Join(home, ".ssh", "id_ecdsa"), - } - - for _, keyPath := range keyPaths { - if _, err := os.Stat(keyPath); err == nil { - key, err := os.ReadFile(keyPath) - if err != nil { - continue - } - - signer, err := ssh.ParsePrivateKey(key) - if err != nil { - continue - } - - return ssh.PublicKeys(signer), nil - } - } - - return nil, fmt.Errorf("no SSH private key found") -} 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 @@ -package state - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" -) - -// State represents the local ship configuration -type State struct { - DefaultHost string `json:"default_host,omitempty"` - Hosts map[string]*Host `json:"hosts"` -} - -// Host represents configuration for a single VPS -type Host struct { - BaseDomain string `json:"base_domain,omitempty"` - GitSetup bool `json:"git_setup,omitempty"` -} - -var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) - -// ValidateName checks that a name is safe for use in shell commands, -// file paths, systemd units, and DNS labels. -func ValidateName(name string) error { - if !validName.MatchString(name) { - 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) - } - return nil -} - -// Load reads state from ~/.config/ship/state.json -func Load() (*State, error) { - path := statePath() - - if _, err := os.Stat(path); os.IsNotExist(err) { - return &State{ - Hosts: make(map[string]*Host), - }, nil - } - - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read state file: %w", err) - } - - var state State - if err := json.Unmarshal(data, &state); err != nil { - return nil, fmt.Errorf("failed to parse state file: %w", err) - } - - if state.Hosts == nil { - state.Hosts = make(map[string]*Host) - } - - return &state, nil -} - -// Save writes state to ~/.config/ship/state.json -func (s *State) Save() error { - path := statePath() - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal state: %w", err) - } - - if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("failed to write state file: %w", err) - } - - return nil -} - -// GetHost returns the host config, creating it if it doesn't exist -func (s *State) GetHost(host string) *Host { - if s.Hosts[host] == nil { - s.Hosts[host] = &Host{} - } - return s.Hosts[host] -} - -// GetDefaultHost returns the default host, or empty string if not set -func (s *State) GetDefaultHost() string { - return s.DefaultHost -} - -// SetDefaultHost sets the default host -func (s *State) SetDefaultHost(host string) { - s.DefaultHost = host -} - -func statePath() string { - home, err := os.UserHomeDir() - if err != nil { - return ".ship-state.json" - } - return filepath.Join(home, ".config", "ship", "state.json") -} 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 @@ -package templates - -import ( - "bytes" - "text/template" -) - -var serviceTemplate = `[Unit] -Description={{.Name}} -After=network.target - -[Service] -Type=simple -User={{.User}} -WorkingDirectory={{.WorkDir}} -EnvironmentFile={{.EnvFile}} -ExecStart={{.BinaryPath}} {{.Args}} -Restart=always -RestartSec=5s -NoNewPrivileges=true -PrivateTmp=true -{{- if .Memory}} -MemoryMax={{.Memory}} -{{- end}} -{{- if .CPU}} -CPUQuota={{.CPU}} -{{- end}} - -[Install] -WantedBy=multi-user.target -` - -var appCaddyTemplate = `{{.Domain}} { - reverse_proxy 127.0.0.1:{{.Port}} -} -` - -var staticCaddyTemplate = `{{.Domain}} { - root * {{.RootDir}} - file_server - encode gzip -} -` - -// SystemdService generates a systemd service unit file -func SystemdService(data map[string]string) (string, error) { - tmpl, err := template.New("service").Parse(serviceTemplate) - if err != nil { - return "", err - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", err - } - - return buf.String(), nil -} - -// AppCaddy generates a Caddy config for a Go app -func AppCaddy(data map[string]string) (string, error) { - tmpl, err := template.New("caddy").Parse(appCaddyTemplate) - if err != nil { - return "", err - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", err - } - - return buf.String(), nil -} - -// StaticCaddy generates a Caddy config for a static site -func StaticCaddy(data map[string]string) (string, error) { - tmpl, err := template.New("caddy").Parse(staticCaddyTemplate) - if err != nil { - return "", err - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", err - } - - return buf.String(), nil -} - -var postReceiveHookTemplate = `#!/bin/bash -set -euo pipefail - -REPO=/srv/git/{{.Name}}.git -SRC=/var/lib/{{.Name}}/src -NAME={{.Name}} - -while read oldrev newrev refname; do - branch=$(git rev-parse --symbolic --abbrev-ref "$refname") - [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; } -done - -# Ensure checkout directory exists -sudo /bin/mkdir -p "$SRC" -sudo /bin/chown -R git:git "/var/lib/${NAME}" - -echo "==> Checking out code..." -git --work-tree="$SRC" --git-dir="$REPO" checkout -f main - -cd "$SRC" - -# If no Dockerfile, nothing to deploy -if [ ! -f Dockerfile ]; then - echo "No Dockerfile found, skipping deploy." - exit 0 -fi - -# Install deployment config from repo (using full paths for sudoers) -if [ -f "$SRC/.ship/service" ]; then - echo "==> Installing systemd unit..." - sudo /bin/cp "$SRC/.ship/service" "/etc/systemd/system/${NAME}.service" - sudo systemctl daemon-reload -fi -if [ -f "$SRC/.ship/Caddyfile" ]; then - echo "==> Installing Caddy config..." - sudo /bin/cp "$SRC/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy" - sudo systemctl reload caddy -fi - -# Ensure data directory exists -sudo /bin/mkdir -p "/var/lib/${NAME}/data" -sudo /bin/chown -R git:git "/var/lib/${NAME}/data" - -echo "==> Building Docker image..." -docker build -t ${NAME}:latest . - -echo "==> Restarting service..." -sudo systemctl restart ${NAME} - -echo "==> Deploy complete!" -` - -var postReceiveHookStaticTemplate = `#!/bin/bash -set -euo pipefail - -REPO=/srv/git/{{.Name}}.git -WEBROOT=/var/www/{{.Name}} -NAME={{.Name}} - -while read oldrev newrev refname; do - branch=$(git rev-parse --symbolic --abbrev-ref "$refname") - [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; } -done - -echo "==> Deploying static site..." -git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main - -if [ -f "$WEBROOT/.ship/Caddyfile" ]; then - echo "==> Installing Caddy config..." - sudo /bin/cp "$WEBROOT/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy" - sudo systemctl reload caddy -fi - -echo "==> Deploy complete!" -` - -var codeCaddyTemplate = `{{.BaseDomain}} { - @goget query go-get=1 - handle @goget { - root * /opt/ship/vanity - templates - rewrite * /index.html - file_server - } - - @git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$" - handle @git { - reverse_proxy unix//run/fcgiwrap.socket { - transport fastcgi { - env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend - env GIT_PROJECT_ROOT /srv/git - env REQUEST_METHOD {method} - env QUERY_STRING {query} - env PATH_INFO {path} - } - } - } - - @cgitassets path /cgit/* - handle @cgitassets { - root * /usr/share/cgit - uri strip_prefix /cgit - file_server - } - - handle { - reverse_proxy unix//run/fcgiwrap.socket { - transport fastcgi { - env SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi - env QUERY_STRING {query} - env REQUEST_METHOD {method} - env PATH_INFO {path} - env HTTP_HOST {host} - env SERVER_NAME {host} - } - } - } -} -` - -var dockerServiceTemplate = `[Unit] -Description={{.Name}} -After=network.target docker.service -Requires=docker.service - -[Service] -Type=simple -ExecStartPre=-/usr/bin/docker rm -f {{.Name}} -ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ - -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \ - --env-file /etc/ship/env/{{.Name}}.env \ - -v /var/lib/{{.Name}}/data:/data \ - {{.Name}}:latest -ExecStop=/usr/bin/docker stop -t 10 {{.Name}} -Restart=always -RestartSec=5s - -[Install] -WantedBy=multi-user.target -` - -var defaultAppCaddyTemplate = `{{.Domain}} { - reverse_proxy 127.0.0.1:{{.Port}} -} -` - -var defaultStaticCaddyTemplate = `{{.Domain}} { - root * /var/www/{{.Name}} - file_server - encode gzip -} -` - -// PostReceiveHook generates a post-receive hook for git-app repos -func PostReceiveHook(data map[string]string) (string, error) { - return renderTemplate("post-receive", postReceiveHookTemplate, data) -} - -// PostReceiveHookStatic generates a post-receive hook for git-static repos -func PostReceiveHookStatic(data map[string]string) (string, error) { - return renderTemplate("post-receive-static", postReceiveHookStaticTemplate, data) -} - -// CodeCaddy generates the base domain Caddy config for vanity imports + git HTTP -func CodeCaddy(data map[string]string) (string, error) { - return renderTemplate("code-caddy", codeCaddyTemplate, data) -} - -var cgitrcTemplate = `virtual-root=/ -css=/cgit/cgit.css -logo=/cgit/cgit.png -header=/opt/ship/cgit-header.html -scan-path=/srv/git/ -export-ok=git-daemon-export-ok -enable-http-clone=0 -clone-url=https://{{.BaseDomain}}/$CGIT_REPO_URL -root-title={{.BaseDomain}} -root-desc= -remove-suffix=.git -` - -var cgitHeaderTemplate = ` -` - -// CgitRC generates the /etc/cgitrc config file -func CgitRC(data map[string]string) (string, error) { - return renderTemplate("cgitrc", cgitrcTemplate, data) -} - -// CgitHeader generates the cgit header HTML file (dark theme) -func CgitHeader() string { - return cgitHeaderTemplate -} - -// DockerService generates a systemd unit for a Docker-based app -func DockerService(data map[string]string) (string, error) { - return renderTemplate("docker-service", dockerServiceTemplate, data) -} - -// DefaultAppCaddy generates a default Caddyfile for a git-app -func DefaultAppCaddy(data map[string]string) (string, error) { - return renderTemplate("default-app-caddy", defaultAppCaddyTemplate, data) -} - -// DefaultStaticCaddy generates a default Caddyfile for a git-static site -func DefaultStaticCaddy(data map[string]string) (string, error) { - return renderTemplate("default-static-caddy", defaultStaticCaddyTemplate, data) -} - -func renderTemplate(name, tmplStr string, data map[string]string) (string, error) { - tmpl, err := template.New(name).Parse(tmplStr) - if err != nil { - return "", err - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", err - } - - return buf.String(), nil -} -- cgit v1.2.3