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. --- .gitignore | 20 +- Makefile | 11 - SECURITY.md | 55 ----- SKILLS_PLAN.md | 135 ------------ cmd/ship/commands.go | 365 -------------------------------- cmd/ship/deploy.go | 210 ------------------- cmd/ship/deploy_impl.go | 394 ----------------------------------- cmd/ship/host.go | 445 ---------------------------------------- cmd/ship/main.go | 10 - cmd/ship/root.go | 98 --------- go.mod | 14 -- go.sum | 16 -- 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 -------------------------------- templates/app.caddy.tmpl | 3 - templates/service.tmpl | 17 -- templates/static.caddy.tmpl | 5 - test/example-website/about.html | 42 ---- test/example-website/index.html | 46 ----- test/example-website/style.css | 156 -------------- website/index.html | 216 ------------------- 24 files changed, 4 insertions(+), 3442 deletions(-) delete mode 100644 Makefile delete mode 100644 SECURITY.md delete mode 100644 SKILLS_PLAN.md delete mode 100644 cmd/ship/commands.go delete mode 100644 cmd/ship/deploy.go delete mode 100644 cmd/ship/deploy_impl.go delete mode 100644 cmd/ship/host.go delete mode 100644 cmd/ship/main.go delete mode 100644 cmd/ship/root.go delete mode 100644 go.mod delete mode 100644 go.sum 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 delete mode 100644 templates/app.caddy.tmpl delete mode 100644 templates/service.tmpl delete mode 100644 templates/static.caddy.tmpl delete mode 100644 test/example-website/about.html delete mode 100644 test/example-website/index.html delete mode 100644 test/example-website/style.css delete mode 100644 website/index.html diff --git a/.gitignore b/.gitignore index a2dd99e..9de1885 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,4 @@ -# Binaries -/ship -/ship-new -*.exe -*.dll -*.so -*.dylib -bin/ - -# Test binary -*.test - -# Go workspace file -go.work - -# IDE +# Editors .vscode/ .idea/ *.swp @@ -22,3 +7,6 @@ go.work # Claude local settings .claude/ + +# Syncthing conflict files +*.sync-conflict-* diff --git a/Makefile b/Makefile deleted file mode 100644 index abab996..0000000 --- a/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -.PHONY: build install deploy-website - -build: - go build -o ./bin/ship ./cmd/ship - -install: - cp ./bin/ship /usr/local/bin/ - -deploy-website: - ship website/ ship.northwest.io - diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 2d7a96e..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,55 +0,0 @@ -# Security Model & Known Gaps - -Ship is a single-user VPS deployment tool. The threat model assumes: -- You control the VPS and have root SSH access -- You trust everyone who has SSH push access (their keys are copied to the `git` user) -- The VPS runs only your own apps - -## Mitigations in place - -### App name validation -All app/project names are validated against `^[a-z][a-z0-9-]{0,62}$` before being used in shell commands, file paths, systemd units, or DNS labels. This prevents command injection via crafted names. - -### Scoped sudoers -The `git` user's sudo rules are restricted to specific paths: -- `systemctl restart/enable` only for services matching `[a-z]*` -- `cp` only from `.ship/` subdirectories to `/etc/systemd/system/` and `/etc/caddy/sites-enabled/` -- `mkdir` only under `/var/lib/` and `/var/www/` -- `chown` only for `git:git` under `/var/lib/` and `/var/www/` - -### Scoped safe.directory -Git's `safe.directory` is set only for the `www-data` user (not system-wide), preserving CVE-2022-24765 protection for other users. - -## Accepted risks (by design) - -### SSH key access = root access -The `git` user is in the `docker` group, which is root-equivalent (can mount the host filesystem). Additionally, `.ship/service` files pushed via git are installed as systemd units. Anyone with SSH push access effectively has root. This is intentional for a single-user tool. - -### Git repo visibility -Repos are private by default (not cloneable over HTTPS). Use `ship init --public` to make a repo publicly cloneable. This is controlled by the `git-daemon-export-ok` marker file in each bare repo. Only public repos are accessible via `go get` or `git clone` over HTTPS. The cgit web interface respects the same model — it is configured with `export-ok=git-daemon-export-ok`, so only public repos are browsable. - -### User-controlled systemd units -The `.ship/service` file in each repo is copied to `/etc/systemd/system/` on push. A malicious service file could run arbitrary commands as root. This is equivalent to the Docker access risk above. - -## Known gaps (not yet addressed) - -### SSH host key verification disabled -`ssh.InsecureIgnoreHostKey()` is used for all SSH connections, and `StrictHostKeyChecking=no` for scp/rsync. This makes connections vulnerable to MITM attacks on untrusted networks. A future improvement would use `known_hosts` verification. - -### Env files may have loose permissions -Environment files at `/etc/ship/env/{name}.env` are created via `sudo tee` and may be world-readable depending on umask. These files can contain secrets. The `deploy` flow does `chmod 600` but `ship init` does not. A future improvement would ensure consistent restrictive permissions. - -### host init is not idempotent -Running `ship host init` twice will overwrite `/etc/caddy/Caddyfile` and the base domain Caddy config, destroying any manual edits. No guard checks whether setup has already been completed. - -### No rollback on failed docker build -The post-receive hook installs `.ship/service` and `.ship/Caddyfile` before running `docker build`. If the build fails, the configs are updated but the old image is still running, creating a mismatch. The old container keeps running (due to `set -e`), but a manual restart would use the new (mismatched) unit file. - -### ship deploy vs git push ownership mismatch -`ship deploy` runs commands as root (the SSH user), while `git push` triggers the hook as the `git` user. Files checked out by `ship deploy` become root-owned, which can prevent subsequent `git push` deploys from overwriting them. - -### No concurrent push protection -Simultaneous pushes can race on the checkout directory and docker build. For single-user usage this is unlikely but not impossible. - -### Port allocation is monotonic -Ports are never reclaimed when apps are removed. After ~57,000 create/remove cycles, ports would be exhausted. Not a practical concern. diff --git a/SKILLS_PLAN.md b/SKILLS_PLAN.md deleted file mode 100644 index ded2b38..0000000 --- a/SKILLS_PLAN.md +++ /dev/null @@ -1,135 +0,0 @@ -# Ship Skills — Reimagining Ship as Claude Skills - -## The Idea - -Rather than a monolithic CLI that bakes in rigid assumptions, ship becomes a family of -narrow, composable Claude skills. Each skill knows how to do one thing well. Claude -provides the reasoning and orchestration. The server is the source of truth. - -Skills are completely generic — no hostnames, app names, or passwords baked in. The -same skills work for anyone. Share them with a friend, point them at a different VPS, -they just work. - -## Shared Configuration - -A single static file at `~/.config/ship/config.json` holds the VPS host (and little -else). All skills read from this file. No vault dependency — works for anyone. - -```json -{ - "host": "ubuntu@1.2.3.4", - "domain": "example.com" -} -``` - -The server itself is the source of truth for everything else — what services are -running, what ports are allocated, what Caddy configs exist. No local state file that -can go stale. - -## The Skills - -### `ship-setup` -One-time setup. Asks for VPS host if not configured, saves to `~/.config/ship/config.json`, -SSHes in and installs server dependencies (Caddy, directory structure, etc). -All other skills depend on this having been run once. - -### `ship-status` -Derives current state entirely from the server at runtime: -- Running apps → `systemctl list-units --type=service` -- Ports → `/etc/ship/ports/` or env files -- Domains → parse Caddy configs in `sites-enabled/` -- Static sites → list `/var/www/` - -No state file needed. Always accurate. Replaces the need for any local tracking. - -### `ship-env` -Read and write env vars with merge semantics. Never overwrites — reads existing file -first, merges new values on top, writes result. Old vars survive redeployments. - -### `ship-caddy` -Manage per-app Caddyfile config. Knows Caddy syntax. Diffs before writing. Validates -before reloading. Never regenerates from scratch — only touches what needs changing. - -### `ship-service` -Systemd management. Handles the difference between a new service (enable + start) and -an existing one (restart). Status, logs, restart, stop — all covered. - -### `ship-binary` -Upload and install a pre-built binary. SCP to `/tmp`, move to `/usr/local/bin/`, -chmod +x, set up work directory and service user. Calls `ship-service` and `ship-env` -to complete the deployment. - -### `ship-static` -Rsync a local dist folder to `/var/www/{name}` on the server. Calls `ship-caddy` to -configure serving. - -### `ship-deploy` -A runbook skill that orchestrates the others in the right order for a full deployment. -Not imperative code — just a checklist of steps with enough context for Claude to -reason about what to do. Adapts based on what the user tells it (binary vs static, -what env vars are needed, etc). - -## What the Server Knows - -All persistent state lives on the server in conventional locations: - -``` -/etc/caddy/sites-enabled/{name}.caddy # per-app Caddy config -/etc/ship/env/{name}.env # environment variables -/etc/ship/ports/{name} # allocated port number -/etc/systemd/system/{name}.service # systemd unit -/var/www/{name}/ # static site files -/var/lib/{name}/ # app work directory (binary, data) -/usr/local/bin/{name} # binary executable -``` - -## Why This Is Better Than the CLI - -- **Transparent** — Claude tells you what it's about to do before doing it -- **Flexible** — no rigid assumptions, Claude reasons about edge cases -- **Mergeable** — env files, Caddy configs never blindly overwritten -- **Debuggable** — if something goes wrong, just ask Claude to fix it -- **Shareable** — no app-specific knowledge baked in, works for anyone -- **No stale state** — server is always the source of truth - -## Per-App Notes (Optional) - -The server can't know things like "this app needs FOODTRACKER_PASSWORD on redeploy" -or "this app has SQLite at /var/lib/foodtracker/data/". That's documentation, not -state. Users can keep these as plain notes in whatever system they prefer — a vault, -a README, a comment in a script. The skills don't depend on it. - -## SQLite Backup - -Before swapping a binary, `ship-binary` checks `/var/lib/{name}/` for any `.db` files -and backs them up to `/var/lib/{name}/backups/{timestamp}.db` before proceeding. Silent -and automatic — you never lose data from a bad deploy. - -## Multi-Host Support - -Config supports multiple named hosts. One is marked as default. All skills use the -default unless told otherwise. - -```json -{ - "default": "prod", - "hosts": { - "prod": { - "host": "ubuntu@1.2.3.4", - "domain": "example.com" - }, - "staging": { - "host": "ubuntu@5.6.7.8", - "domain": "staging.example.com" - } - } -} -``` - -Usage is natural — "deploy foodtracker to staging" and Claude picks the right host. -`ship-setup` can be run multiple times to add new hosts. The default can be changed -at any time. - -## Out of Scope (For Now) - -- Health checks — skipping initially, can add later if needed diff --git a/cmd/ship/commands.go b/cmd/ship/commands.go deleted file mode 100644 index 1b0d09c..0000000 --- a/cmd/ship/commands.go +++ /dev/null @@ -1,365 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -// listV2Cmd lists all deployments -var listV2Cmd = &cobra.Command{ - Use: "list", - Short: "List all deployments", - RunE: runListV2, -} - -func runListV2(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - hostConfig := st.GetHost(hostName) - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - var deploys []output.DeployInfo - - // Get all deployed services by checking /etc/ship/ports and /var/www - // Check ports (apps and docker) - portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true") - for _, name := range strings.Fields(portsOut) { - if name == "" { - continue - } - - // Get actual domain from Caddyfile (first word of first line) - domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) - caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) - if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { - domain = d - } - - info := output.DeployInfo{ - Name: name, - URL: fmt.Sprintf("https://%s", domain), - } - - // Check if it's docker or binary - dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) - if strings.Contains(dockerOut, "docker") { - info.Type = "docker" - } else { - info.Type = "binary" - } - - // Check if running - statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) - info.Running = strings.TrimSpace(statusOut) == "active" - - // Check TTL - ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) - if ttlOut != "" { - if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { - info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) - } - } - - deploys = append(deploys, info) - } - - // Check static sites in /var/www - wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true") - for _, name := range strings.Fields(wwwOut) { - if name == "" || name == "html" { - continue - } - - // Skip if already in ports (would be an app, not static) - found := false - for _, d := range deploys { - if d.Name == name { - found = true - break - } - } - if found { - continue - } - - // Get actual domain from Caddyfile - domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) - caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) - if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { - domain = d - } - - info := output.DeployInfo{ - Name: name, - URL: fmt.Sprintf("https://%s", domain), - Type: "static", - Running: true, // Static sites are always "running" - } - - // Check TTL - ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) - if ttlOut != "" { - if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { - info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) - } - } - - deploys = append(deploys, info) - } - - output.PrintAndExit(&output.ListResponse{ - Status: "ok", - Deploys: deploys, - }) - return nil -} - -// statusV2Cmd shows status for a single deployment -var statusV2Cmd = &cobra.Command{ - Use: "status NAME", - Short: "Check status of a deployment", - Args: cobra.ExactArgs(1), - RunE: runStatusV2, -} - -func runStatusV2(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - hostConfig := st.GetHost(hostName) - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check if deployment exists - portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) - wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) - - if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { - output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) - } - - // Get actual domain from Caddyfile - domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) - caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) - if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { - domain = d - } - - resp := &output.StatusResponse{ - Status: "ok", - Name: name, - URL: fmt.Sprintf("https://%s", domain), - } - - // Determine type and get details - if portOut != "" { - port, _ := strconv.Atoi(strings.TrimSpace(portOut)) - resp.Port = port - - // Check if docker - dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) - if strings.Contains(dockerOut, "docker") { - resp.Type = "docker" - } else { - resp.Type = "binary" - } - - // Check if running - statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) - resp.Running = strings.TrimSpace(statusOut) == "active" - } else { - resp.Type = "static" - resp.Running = true - } - - // Check TTL - ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) - if ttlOut != "" { - if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { - resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) - } - } - - output.PrintAndExit(resp) - return nil -} - -// logsV2Cmd shows logs for a deployment -var logsV2Cmd = &cobra.Command{ - Use: "logs NAME", - Short: "View logs for a deployment", - Args: cobra.ExactArgs(1), - RunE: runLogsV2, -} - -func init() { - logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show") -} - -func runLogsV2(cmd *cobra.Command, args []string) error { - name := args[0] - lines, _ := cmd.Flags().GetInt("lines") - - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check if it's a static site (no logs) - portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) - if strings.TrimSpace(portOut) == "" { - // Check if static site exists - wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) - if strings.TrimSpace(wwwExists) == "" { - output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) - } - // Static site - check Caddy access logs - logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u caddy -n %d --no-pager 2>/dev/null | grep %s || echo 'No logs found'", lines*2, name)) - if err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) - } - logLines := strings.Split(strings.TrimSpace(logsOut), "\n") - output.PrintAndExit(&output.LogsResponse{ - Status: "ok", - Name: name, - Lines: logLines, - }) - return nil - } - - // Get journalctl logs - logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines)) - if err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) - } - - logLines := strings.Split(strings.TrimSpace(logsOut), "\n") - - output.PrintAndExit(&output.LogsResponse{ - Status: "ok", - Name: name, - Lines: logLines, - }) - return nil -} - -// removeV2Cmd removes a deployment -var removeV2Cmd = &cobra.Command{ - Use: "remove NAME", - Short: "Remove a deployment", - Args: cobra.ExactArgs(1), - RunE: runRemoveV2, -} - -func runRemoveV2(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check if deployment exists - portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) - wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) - - if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { - output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) - } - - // Stop and disable service - client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name)) - client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name)) - - // Remove files - client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) - client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) - client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name)) - - // Remove docker container and image - client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name)) - client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name)) - - // Reload services - client.RunSudo("systemctl daemon-reload") - client.RunSudo("systemctl reload caddy") - - output.PrintAndExit(&output.RemoveResponse{ - Status: "ok", - Name: name, - Removed: true, - }) - return nil -} diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go deleted file mode 100644 index 7d498b2..0000000 --- a/cmd/ship/deploy.go +++ /dev/null @@ -1,210 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "regexp" - "strings" - "time" - - "github.com/bdw/ship/internal/detect" - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/state" -) - -// deployV2 implements the new agent-first deploy interface. -// Usage: ship [PATH] [FLAGS] -// PATH defaults to "." if not provided. -func deployV2(path string, opts deployV2Options) { - start := time.Now() - - // Validate name if provided - if opts.Name != "" { - if err := validateNameV2(opts.Name); err != nil { - output.PrintAndExit(err) - } - } - - // Parse TTL if provided - var ttlDuration time.Duration - if opts.TTL != "" { - var err error - ttlDuration, err = parseTTL(opts.TTL) - if err != nil { - output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) - } - } - - // Get host configuration - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) - } - - hostName := opts.Host - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) - } - - hostConfig := st.GetHost(hostName) - if hostConfig.BaseDomain == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) - } - - // Auto-detect project type - result := detect.Detect(path) - if result.Error != nil { - output.PrintAndExit(result.Error) - } - - // Generate name if not provided - name := opts.Name - if name == "" { - name = generateName() - } - - // Build URL: use custom domain if provided, otherwise use subdomain - var url string - if opts.Domain != "" { - url = fmt.Sprintf("https://%s", opts.Domain) - } else { - url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) - } - - // Build deploy context - ctx := &deployContext{ - SSHHost: hostName, - HostConfig: hostConfig, - Name: name, - Path: result.Path, - URL: url, - Opts: opts, - } - - // Deploy based on type - var deployErr *output.ErrorResponse - switch result.Type { - case detect.TypeStatic: - deployErr = deployStaticV2(ctx) - case detect.TypeDocker: - deployErr = deployDockerV2(ctx) - case detect.TypeBinary: - deployErr = deployBinaryV2(ctx) - } - - if deployErr != nil { - deployErr.Name = name - deployErr.URL = url - output.PrintAndExit(deployErr) - } - - // Set TTL if specified - if ttlDuration > 0 { - if err := setTTLV2(ctx, ttlDuration); err != nil { - // Non-fatal, deploy succeeded - // TODO: log warning - } - } - - // Health check - var healthResult *output.HealthResult - if opts.Health != "" || result.Type == detect.TypeStatic { - endpoint := opts.Health - if endpoint == "" { - endpoint = "/" - } - healthResult, deployErr = runHealthCheck(url, endpoint) - if deployErr != nil { - deployErr.Name = name - deployErr.URL = url - output.PrintAndExit(deployErr) - } - } - - // Build response - resp := &output.DeployResponse{ - Status: "ok", - Name: name, - URL: url, - Type: string(result.Type), - TookMs: time.Since(start).Milliseconds(), - Health: healthResult, - } - - if ttlDuration > 0 { - resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) - } - - output.PrintAndExit(resp) -} - -type deployV2Options struct { - Name string - Host string - Domain string - Health string - TTL string - Env []string - EnvFile string - ContainerPort int // Port the container listens on (default 80 for Docker) - Pretty bool -} - -// deployContext holds all info needed for a deploy -type deployContext struct { - SSHHost string // SSH connection string (config alias or user@host) - HostConfig *state.Host // Host configuration - Name string // Deploy name - Path string // Local path to deploy - URL string // Full URL after deploy - Opts deployV2Options -} - -// validateNameV2 checks if name matches allowed pattern -func validateNameV2(name string) *output.ErrorResponse { - // Must be lowercase alphanumeric with hyphens, 1-63 chars - pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) - if !pattern.MatchString(name) { - return output.Err(output.ErrInvalidName, - "name must be lowercase alphanumeric with hyphens, 1-63 characters") - } - return nil -} - -// generateName creates a random deploy name -func generateName() string { - bytes := make([]byte, 3) - rand.Read(bytes) - return "ship-" + hex.EncodeToString(bytes) -} - -// parseTTL converts duration strings like "1h", "7d" to time.Duration -func parseTTL(s string) (time.Duration, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, nil - } - - // Handle days specially (not supported by time.ParseDuration) - if strings.HasSuffix(s, "d") { - days := strings.TrimSuffix(s, "d") - var d int - _, err := fmt.Sscanf(days, "%d", &d) - if err != nil { - return 0, fmt.Errorf("invalid TTL: %s", s) - } - return time.Duration(d) * 24 * time.Hour, nil - } - - d, err := time.ParseDuration(s) - if err != nil { - return 0, fmt.Errorf("invalid TTL: %s", s) - } - return d, nil -} - -// Deploy implementations are in deploy_impl_v2.go diff --git a/cmd/ship/deploy_impl.go b/cmd/ship/deploy_impl.go deleted file mode 100644 index bfec9d3..0000000 --- a/cmd/ship/deploy_impl.go +++ /dev/null @@ -1,394 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/templates" -) - -// deployStaticV2 deploys a static site -// 1. rsync path to /var/www// -// 2. Generate and upload Caddyfile -// 3. Reload Caddy -func deployStaticV2(ctx *deployContext) *output.ErrorResponse { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return output.Err(output.ErrSSHConnectFailed, err.Error()) - } - defer client.Close() - - name := ctx.Name - remotePath := fmt.Sprintf("/var/www/%s", name) - - // Create directory and set ownership for upload - user, _ := client.Run("whoami") - user = strings.TrimSpace(user) - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) - } - if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) - } - - // Upload files using rsync - if err := client.UploadDir(ctx.Path, remotePath); err != nil { - return output.Err(output.ErrUploadFailed, err.Error()) - } - - // Set ownership back to www-data - if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { - // Non-fatal, continue - } - - // Generate Caddyfile only if it doesn't exist (preserve manual edits) - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) - if strings.TrimSpace(caddyExists) != "exists" { - caddyfile, err := templates.StaticCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "RootDir": remotePath, - "Name": name, - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) - } - } - - // Reload Caddy - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) - } - - return nil -} - -// deployDockerV2 deploys a Docker-based app -// 1. Allocate port -// 2. rsync path to /var/lib//src/ -// 3. docker build -// 4. Generate systemd unit and env file -// 5. Generate Caddyfile -// 6. Start service, reload Caddy -func deployDockerV2(ctx *deployContext) *output.ErrorResponse { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return output.Err(output.ErrSSHConnectFailed, err.Error()) - } - defer client.Close() - - name := ctx.Name - - // Allocate port on server - port, err := allocatePort(client, name) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) - } - - srcPath := fmt.Sprintf("/var/lib/%s/src", name) - dataPath := fmt.Sprintf("/var/lib/%s/data", name) - - // Create directories - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) - } - - // Set ownership for upload - user, _ := client.Run("whoami") - user = strings.TrimSpace(user) - if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) - } - - // Upload source - if err := client.UploadDir(ctx.Path, srcPath); err != nil { - return output.Err(output.ErrUploadFailed, err.Error()) - } - - // Docker build - buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) - if _, err := client.RunSudo(buildCmd); err != nil { - return output.Err(output.ErrBuildFailed, err.Error()) - } - - // Determine container port - containerPort := ctx.Opts.ContainerPort - if containerPort == 0 { - containerPort = 80 - } - - // Generate and write env file - // Use containerPort so the app listens on the correct port inside the container - envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL) - for _, e := range ctx.Opts.Env { - envContent += e + "\n" - } - envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) - if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { - // Continue, directory might exist - } - if err := client.WriteSudoFile(envPath, envContent); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) - } - - // Generate systemd unit - service, err := templates.DockerService(map[string]string{ - "Name": name, - "Port": strconv.Itoa(port), - "ContainerPort": strconv.Itoa(containerPort), - }) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) - if err := client.WriteSudoFile(servicePath, service); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) - } - - // Generate Caddyfile only if it doesn't exist (preserve manual edits) - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) - if strings.TrimSpace(caddyExists) != "exists" { - caddyfile, err := templates.AppCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "Port": strconv.Itoa(port), - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) - } - } - - // Reload systemd and start service - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) - } - - // Reload Caddy - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) - } - - return nil -} - -// deployBinaryV2 deploys a pre-built binary -// 1. Allocate port -// 2. scp binary to /usr/local/bin/ -// 3. Create user for service -// 4. Generate systemd unit and env file -// 5. Generate Caddyfile -// 6. Start service, reload Caddy -func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return output.Err(output.ErrSSHConnectFailed, err.Error()) - } - defer client.Close() - - name := ctx.Name - - // Allocate port on server - port, err := allocatePort(client, name) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) - } - - binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) - workDir := fmt.Sprintf("/var/lib/%s", name) - - // Upload binary - if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { - return output.Err(output.ErrUploadFailed, err.Error()) - } - - // Move to final location and set permissions - if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) - } - - // Create work directory - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { - return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) - } - - // Create service user (ignore error if exists) - client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) - client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) - - // Generate and write env file - envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) - for _, e := range ctx.Opts.Env { - envContent += e + "\n" - } - envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) - if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { - // Continue - } - if err := client.WriteSudoFile(envPath, envContent); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) - } - - // Generate systemd unit - service, err := templates.SystemdService(map[string]string{ - "Name": name, - "User": name, - "WorkDir": workDir, - "EnvFile": envPath, - "BinaryPath": binaryPath, - "Args": "", - }) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) - if err := client.WriteSudoFile(servicePath, service); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) - } - - // Generate Caddyfile only if it doesn't exist (preserve manual edits) - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) - if strings.TrimSpace(caddyExists) != "exists" { - caddyfile, err := templates.AppCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "Port": strconv.Itoa(port), - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) - } - } - - // Reload systemd and start service - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { - return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) - } - - // Reload Caddy - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) - } - - return nil -} - -// allocatePort allocates or retrieves a port for a service -// Uses atomic increment on /etc/ship/next_port to avoid collisions -func allocatePort(client *ssh.Client, name string) (int, error) { - portFile := fmt.Sprintf("/etc/ship/ports/%s", name) - - // Try to read existing port for this app - out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) - if err == nil && out != "" { - out = strings.TrimSpace(out) - if port, err := strconv.Atoi(out); err == nil && port > 0 { - return port, nil - } - } - - // Allocate new port atomically using flock - // Scans existing port files to avoid collisions even if next_port is stale - allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'` - out, err = client.RunSudo(allocScript) - if err != nil { - return 0, fmt.Errorf("failed to allocate port: %w", err) - } - - port, err := strconv.Atoi(strings.TrimSpace(out)) - if err != nil { - return 0, fmt.Errorf("invalid port allocated: %s", out) - } - - // Write port allocation for this app - if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { - return 0, err - } - - return port, nil -} - -// setTTLV2 sets auto-expiry for a deploy -func setTTLV2(ctx *deployContext, ttl time.Duration) error { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return err - } - defer client.Close() - - expires := time.Now().Add(ttl).Unix() - ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) - - if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { - return err - } - - return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) -} - -// runHealthCheck verifies the deploy is responding -func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { - fullURL := url + endpoint - - // Wait for app to start - time.Sleep(2 * time.Second) - - var lastErr error - var lastStatus int - - for i := 0; i < 15; i++ { - start := time.Now() - resp, err := http.Get(fullURL) - latency := time.Since(start).Milliseconds() - - if err != nil { - lastErr = err - time.Sleep(2 * time.Second) - continue - } - resp.Body.Close() - lastStatus = resp.StatusCode - - if resp.StatusCode >= 200 && resp.StatusCode < 400 { - return &output.HealthResult{ - Endpoint: endpoint, - Status: resp.StatusCode, - LatencyMs: latency, - }, nil - } - - time.Sleep(2 * time.Second) - } - - msg := fmt.Sprintf("health check failed after 30s: ") - if lastErr != nil { - msg += lastErr.Error() - } else { - msg += fmt.Sprintf("status %d", lastStatus) - } - - return nil, output.Err(output.ErrHealthCheckFailed, msg) -} diff --git a/cmd/ship/host.go b/cmd/ship/host.go deleted file mode 100644 index b19c376..0000000 --- a/cmd/ship/host.go +++ /dev/null @@ -1,445 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/bdw/ship/internal/templates" - "github.com/spf13/cobra" -) - -func initHostV2() { - hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") - hostInitV2Cmd.MarkFlagRequired("domain") - - hostV2Cmd.AddCommand(hostInitV2Cmd) - hostV2Cmd.AddCommand(hostStatusV2Cmd) -} - -var hostInitV2Cmd = &cobra.Command{ - Use: "init USER@HOST --domain DOMAIN", - Short: "Initialize a VPS for deployments", - Long: `Set up a fresh VPS with Caddy, Docker, and required directories. - -Example: - ship host init user@my-vps --domain example.com`, - Args: cobra.ExactArgs(1), - RunE: runHostInitV2, -} - -func runHostInitV2(cmd *cobra.Command, args []string) error { - host := args[0] - domain, _ := cmd.Flags().GetString("domain") - - if domain == "" { - output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) - } - - // Ensure SSH key exists - keyPath, pubkey, err := ensureSSHKey() - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) - } - - // Try to connect first (to verify key is authorized) - client, err := ssh.Connect(host) - if err != nil { - // Connection failed - provide helpful error with pubkey - resp := map[string]interface{}{ - "status": "error", - "code": "SSH_AUTH_FAILED", - "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", - "public_key": pubkey, - "key_path": keyPath, - "host": host, - "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), - } - printJSON(resp) - os.Exit(output.ExitSSHFailed) - } - defer client.Close() - - // Detect OS - osRelease, err := client.Run("cat /etc/os-release") - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) - } - - if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { - output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) - } - - var installed []string - - // Install Caddy if needed - if _, err := client.Run("which caddy"); err != nil { - if err := installCaddyV2(client); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) - } - installed = append(installed, "caddy") - } - - // Configure Caddy - caddyfile := `{ -} - -import /etc/caddy/sites-enabled/* -` - if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { - output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) - } - - // Create directories - dirs := []string{ - "/etc/ship/env", - "/etc/ship/ports", - "/etc/ship/ttl", - "/etc/caddy/sites-enabled", - "/var/www", - } - for _, dir := range dirs { - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) - } - } - - // Install Docker - if _, err := client.Run("which docker"); err != nil { - if err := installDockerV2(client); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) - } - installed = append(installed, "docker") - } - - // Install cleanup timer for TTL - if err := installCleanupTimer(client); err != nil { - // Non-fatal - } - - // Enable and start services - if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) - } - - // Save state - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) - } - - hostState := st.GetHost(host) - hostState.BaseDomain = domain - - if st.GetDefaultHost() == "" { - st.SetDefaultHost(host) - } - - if err := st.Save(); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) - } - - // Success - output.PrintAndExit(&output.HostInitResponse{ - Status: "ok", - Host: host, - Domain: domain, - Installed: installed, - }) - - return nil -} - -func installCaddyV2(client *ssh.Client) error { - commands := []string{ - "apt-get update", - "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", - "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", - "apt-get update", - "apt-get install -y caddy", - } - - for _, cmd := range commands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("command failed: %s: %w", cmd, err) - } - } - return nil -} - -func installDockerV2(client *ssh.Client) error { - commands := []string{ - "apt-get install -y ca-certificates curl gnupg", - "install -m 0755 -d /etc/apt/keyrings", - "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", - "chmod a+r /etc/apt/keyrings/docker.asc", - `sh -c 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list'`, - "apt-get update", - "apt-get install -y docker-ce docker-ce-cli containerd.io", - } - - for _, cmd := range commands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("command failed: %s: %w", cmd, err) - } - } - return nil -} - -func installCleanupTimer(client *ssh.Client) error { - // Cleanup script - script := `#!/bin/bash -now=$(date +%s) -for f in /etc/ship/ttl/*; do - [ -f "$f" ] || continue - name=$(basename "$f") - expires=$(cat "$f") - if [ "$now" -gt "$expires" ]; then - systemctl stop "$name" 2>/dev/null || true - systemctl disable "$name" 2>/dev/null || true - rm -f "/etc/systemd/system/${name}.service" - rm -f "/etc/caddy/sites-enabled/${name}.caddy" - rm -rf "/var/www/${name}" - rm -rf "/var/lib/${name}" - rm -f "/usr/local/bin/${name}" - rm -f "/etc/ship/env/${name}.env" - rm -f "/etc/ship/ports/${name}" - rm -f "/etc/ship/ttl/${name}" - docker rm -f "$name" 2>/dev/null || true - docker rmi "$name" 2>/dev/null || true - fi -done -systemctl daemon-reload -systemctl reload caddy -` - if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { - return err - } - if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { - return err - } - - // Timer unit - timer := `[Unit] -Description=Ship TTL cleanup timer - -[Timer] -OnCalendar=hourly -Persistent=true - -[Install] -WantedBy=timers.target -` - if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { - return err - } - - // Service unit - service := `[Unit] -Description=Ship TTL cleanup - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/ship-cleanup -` - if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { - return err - } - - // Enable timer - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return err - } - if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { - return err - } - - return nil -} - -var hostStatusV2Cmd = &cobra.Command{ - Use: "status", - Short: "Check host status", - RunE: func(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - hostConfig := st.GetHost(hostName) - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check services - caddyStatus, _ := client.RunSudo("systemctl is-active caddy") - dockerStatus, _ := client.RunSudo("systemctl is-active docker") - - // Print as JSON directly (custom response type) - fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", - hostName, - hostConfig.BaseDomain, - strings.TrimSpace(caddyStatus) == "active", - strings.TrimSpace(dockerStatus) == "active", - ) - return nil - }, -} - -// Preserve git setup functionality from v1 for advanced users -func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { - // Install git, fcgiwrap, cgit - if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { - return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) - } - - // Create git user - client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") - client.RunSudo("usermod -aG docker git") - client.RunSudo("usermod -aG git www-data") - client.RunSudo("usermod -aG www-data caddy") - - // Copy SSH keys - copyKeysCommands := []string{ - "mkdir -p /home/git/.ssh", - "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", - "chown -R git:git /home/git/.ssh", - "chmod 700 /home/git/.ssh", - "chmod 600 /home/git/.ssh/authorized_keys", - } - for _, cmd := range copyKeysCommands { - if _, err := client.RunSudo(cmd); err != nil { - return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) - } - } - - // Create /srv/git - client.RunSudo("mkdir -p /srv/git") - client.RunSudo("chown git:git /srv/git") - - // Sudoers - sudoersContent := `git ALL=(ALL) NOPASSWD: \ - /bin/systemctl daemon-reload, \ - /bin/systemctl reload caddy, \ - /bin/systemctl restart [a-z]*, \ - /bin/systemctl enable [a-z]*, \ - /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ - /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ - /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ - /bin/mkdir -p /var/lib/*, \ - /bin/mkdir -p /var/www/*, \ - /bin/chown -R git\:git /var/lib/*, \ - /bin/chown git\:git /var/www/* -` - if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) - } - client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") - - // Vanity import template - vanityHTML := ` - -{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} -{{$parts := splitList "/" $path}} -{{$module := first $parts}} - - -go get {{.Host}}/{{$module}} - -` - client.RunSudo("mkdir -p /opt/ship/vanity") - client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) - - // cgit config - codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) - client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) - - cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) - client.WriteSudoFile("/etc/cgitrc", cgitrcContent) - client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) - - // Start services - client.RunSudo("systemctl enable --now fcgiwrap") - client.RunSudo("systemctl restart caddy") - - hostState.GitSetup = true - return nil -} - -// ensureSSHKey checks for an existing SSH key or generates a new one. -// Returns the key path, public key contents, and any error. -func ensureSSHKey() (keyPath string, pubkey string, err error) { - home, err := os.UserHomeDir() - if err != nil { - return "", "", err - } - - // Check common key locations - keyPaths := []string{ - filepath.Join(home, ".ssh", "id_ed25519"), - filepath.Join(home, ".ssh", "id_rsa"), - filepath.Join(home, ".ssh", "id_ecdsa"), - } - - for _, kp := range keyPaths { - pubPath := kp + ".pub" - if _, err := os.Stat(kp); err == nil { - if _, err := os.Stat(pubPath); err == nil { - // Key exists, read public key - pub, err := os.ReadFile(pubPath) - if err != nil { - continue - } - return kp, strings.TrimSpace(string(pub)), nil - } - } - } - - // No key found, generate one - keyPath = filepath.Join(home, ".ssh", "id_ed25519") - sshDir := filepath.Dir(keyPath) - - // Ensure .ssh directory exists - if err := os.MkdirAll(sshDir, 0700); err != nil { - return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) - } - - // Generate key - cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") - if err := cmd.Run(); err != nil { - return "", "", fmt.Errorf("failed to generate SSH key: %w", err) - } - - // Read public key - pub, err := os.ReadFile(keyPath + ".pub") - if err != nil { - return "", "", fmt.Errorf("failed to read public key: %w", err) - } - - return keyPath, strings.TrimSpace(string(pub)), nil -} - -// printJSON outputs a value as JSON to stdout -func printJSON(v interface{}) { - enc := json.NewEncoder(os.Stdout) - enc.Encode(v) -} diff --git a/cmd/ship/main.go b/cmd/ship/main.go deleted file mode 100644 index 17516fb..0000000 --- a/cmd/ship/main.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import "os" - -func main() { - initV2() - if err := rootV2Cmd.Execute(); err != nil { - os.Exit(1) - } -} diff --git a/cmd/ship/root.go b/cmd/ship/root.go deleted file mode 100644 index aa81d1e..0000000 --- a/cmd/ship/root.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "os" - - "github.com/bdw/ship/internal/output" - "github.com/spf13/cobra" -) - -var hostFlag string - -// This file defines the v2 CLI structure. -// The primary command is: ship [PATH] [FLAGS] -// All output is JSON by default. - -var rootV2Cmd = &cobra.Command{ - Use: "ship [PATH]", - Short: "Deploy code to a VPS. JSON output for agents.", - Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. - - ship ./myproject # auto-detect and deploy - ship ./site --name docs # deploy with specific name - ship ./api --health /healthz # deploy with health check - ship ./preview --ttl 24h # deploy with auto-expiry - -All output is JSON. Use --pretty for human-readable output.`, - Args: cobra.MaximumNArgs(1), - RunE: runDeployV2, - SilenceUsage: true, - SilenceErrors: true, - DisableAutoGenTag: true, -} - -func initV2() { - // Global flags - rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") - rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") - - // Deploy flags - rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") - rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment") - rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") - rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") - rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") - rootV2Cmd.Flags().String("env-file", "", "Path to .env file") - rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") - - // Check for SHIP_PRETTY env var - if os.Getenv("SHIP_PRETTY") == "1" { - output.Pretty = true - } - - // Add subcommands - rootV2Cmd.AddCommand(listV2Cmd) - rootV2Cmd.AddCommand(statusV2Cmd) - rootV2Cmd.AddCommand(logsV2Cmd) - rootV2Cmd.AddCommand(removeV2Cmd) - rootV2Cmd.AddCommand(hostV2Cmd) - - // Initialize host subcommands (from host_v2.go) - initHostV2() -} - -func runDeployV2(cmd *cobra.Command, args []string) error { - path := "." - if len(args) > 0 { - path = args[0] - } - - opts := deployV2Options{ - Host: hostFlag, - Pretty: output.Pretty, - } - - // Get flag values - opts.Name, _ = cmd.Flags().GetString("name") - opts.Domain, _ = cmd.Flags().GetString("domain") - opts.Health, _ = cmd.Flags().GetString("health") - opts.TTL, _ = cmd.Flags().GetString("ttl") - opts.Env, _ = cmd.Flags().GetStringArray("env") - opts.EnvFile, _ = cmd.Flags().GetString("env-file") - opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") - - // deployV2 handles all output and exits - deployV2(path, opts) - - // Should not reach here (deployV2 calls os.Exit) - return nil -} - -// Subcommands (list, status, logs, remove) are defined in commands_v2.go - -var hostV2Cmd = &cobra.Command{ - Use: "host", - Short: "Manage VPS host", -} - -// hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go diff --git a/go.mod b/go.mod deleted file mode 100644 index cc84806..0000000 --- a/go.mod +++ /dev/null @@ -1,14 +0,0 @@ -module github.com/bdw/ship - -go 1.21 - -require ( - github.com/spf13/cobra v1.10.2 - golang.org/x/crypto v0.31.0 -) - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.28.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 42e3e06..0000000 --- a/go.sum +++ /dev/null @@ -1,16 +0,0 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 -} diff --git a/templates/app.caddy.tmpl b/templates/app.caddy.tmpl deleted file mode 100644 index 505d1d9..0000000 --- a/templates/app.caddy.tmpl +++ /dev/null @@ -1,3 +0,0 @@ -{{.Domain}} { - reverse_proxy 127.0.0.1:{{.Port}} -} diff --git a/templates/service.tmpl b/templates/service.tmpl deleted file mode 100644 index 87389f0..0000000 --- a/templates/service.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description={{.Name}} -After=network.target - -[Service] -Type=simple -User={{.User}} -WorkingDirectory={{.WorkDir}} -EnvironmentFile={{.EnvFile}} -ExecStart={{.BinaryPath}} --port={{.Port}} -Restart=always -RestartSec=5s -NoNewPrivileges=true -PrivateTmp=true - -[Install] -WantedBy=multi-user.target diff --git a/templates/static.caddy.tmpl b/templates/static.caddy.tmpl deleted file mode 100644 index d04f6b0..0000000 --- a/templates/static.caddy.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{.Domain}} { - root * {{.RootDir}} - file_server - encode gzip -} diff --git a/test/example-website/about.html b/test/example-website/about.html deleted file mode 100644 index 93cba92..0000000 --- a/test/example-website/about.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - About - Deploy Test - - - -
- -
- -
-
-

About This Site

-

This is a test website created to demonstrate the deploy tool's static site deployment capabilities.

- -

Features

-
    -
  • Simple HTML/CSS structure
  • -
  • Multiple pages for testing navigation
  • -
  • Responsive design
  • -
  • Clean and minimal styling
  • -
- -

Deployment Command

-
./deploy deploy --host peerfile --static --dir ./test/example-website --domain example.com
-
-
- -
-

© 2025 Deploy Test. Built for testing purposes.

-
- - diff --git a/test/example-website/index.html b/test/example-website/index.html deleted file mode 100644 index 735ae73..0000000 --- a/test/example-website/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - Example Website - Deploy Test - - - -
- -
- -
-
-

Welcome to the Example Website

-

This is a simple static website for testing the deploy tool.

-
- -
-
-

Fast Deployment

-

Deploy your static sites in seconds with a single command.

-
-
-

HTTPS Enabled

-

Automatic SSL certificates with Caddy.

-
-
-

Simple Management

-

Easy-to-use CLI for managing your deployments.

-
-
-
- -
-

© 2025 Deploy Test. Built for testing purposes.

-
- - diff --git a/test/example-website/style.css b/test/example-website/style.css deleted file mode 100644 index da7fd1c..0000000 --- a/test/example-website/style.css +++ /dev/null @@ -1,156 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - line-height: 1.6; - color: #333; - background: #f5f5f5; -} - -header { - background: #2c3e50; - color: white; - padding: 1rem 0; - box-shadow: 0 2px 5px rgba(0,0,0,0.1); -} - -nav { - max-width: 1200px; - margin: 0 auto; - padding: 0 2rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -nav h1 { - font-size: 1.5rem; -} - -nav ul { - list-style: none; - display: flex; - gap: 2rem; -} - -nav a { - color: white; - text-decoration: none; - transition: opacity 0.3s; -} - -nav a:hover { - opacity: 0.8; -} - -main { - max-width: 1200px; - margin: 2rem auto; - padding: 0 2rem; -} - -.hero { - background: white; - padding: 3rem; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); - text-align: center; - margin-bottom: 2rem; -} - -.hero h2 { - font-size: 2.5rem; - margin-bottom: 1rem; - color: #2c3e50; -} - -.hero p { - font-size: 1.2rem; - color: #666; -} - -.features { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2rem; - margin: 2rem 0; -} - -.feature { - background: white; - padding: 2rem; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); -} - -.feature h3 { - color: #2c3e50; - margin-bottom: 0.5rem; -} - -.feature p { - color: #666; -} - -.about { - background: white; - padding: 3rem; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); -} - -.about h2 { - color: #2c3e50; - margin-bottom: 1rem; -} - -.about h3 { - color: #34495e; - margin-top: 2rem; - margin-bottom: 0.5rem; -} - -.about ul { - margin-left: 2rem; - margin-bottom: 1rem; -} - -.about pre { - background: #f5f5f5; - padding: 1rem; - border-radius: 4px; - overflow-x: auto; - margin-top: 1rem; -} - -.about code { - font-family: 'Courier New', monospace; - font-size: 0.9rem; -} - -footer { - background: #2c3e50; - color: white; - text-align: center; - padding: 2rem; - margin-top: 4rem; -} - -@media (max-width: 768px) { - nav { - flex-direction: column; - gap: 1rem; - } - - .hero h2 { - font-size: 2rem; - } - - .features { - grid-template-columns: 1fr; - } -} diff --git a/website/index.html b/website/index.html deleted file mode 100644 index e7e6d7a..0000000 --- a/website/index.html +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - ship — deploy to your VPS - - - -
-
-

ship

-

Deploy code to your VPS. Get a URL back.

-
- -
-

What it does

-

Point ship at a directory or binary. It figures out what you're deploying, uploads it, configures HTTPS, and gives you a URL. No containers required. No YAML. No config files.

-

Built for AI agents. JSON output by default, predictable behavior, and an agent skill so your assistant can deploy code without hand-holding.

-
- -
-

Usage

-
# static site
-ship ./dist
-→ https://ship-a1b2c3.example.com
-
-# with a name
-ship ./dist --name docs
-→ https://docs.example.com
-
-# binary with health check
-ship ./myapp --name api --health /healthz
-→ https://api.example.com
-
-# temporary preview (auto-deletes)
-ship ./preview --ttl 1h
-→ https://ship-x7y8z9.example.com (expires in 1h)
-
-# custom domain
-ship ./site --domain myapp.com
-→ https://myapp.com
-
- -
-

Features

-
-
- Auto-detection - Static sites, Docker apps, binaries — ship figures it out. -
-
- Automatic HTTPS - Caddy handles certificates. You get HTTPS by default. -
-
- TTL support - Temporary deploys that clean themselves up. -
-
- JSON output - Built for scripts and automation. Parseable by default. -
-
-
- -
-

One-time setup

-
# point ship at your VPS
-ship host init user@your-server --domain example.com
-

That's it. No manual VPS configuration. Init installs everything — Caddy, Docker, systemd services, SSH keys. Just bring a fresh VPS with SSH access (Ubuntu/Debian).

-
- -
-

Commands

-
    -
  • ship <path> — deploy
  • -
  • ship list — show all deployments
  • -
  • ship status <name> — check a deployment
  • -
  • ship logs <name> — view logs
  • -
  • ship remove <name> — take it down
  • -
-
- - -
- - -- cgit v1.2.3