diff options
| author | Clawd <ai@clawd.bot> | 2026-04-18 14:40:17 -0700 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-04-18 14:40:17 -0700 |
| commit | 778bef5ee6941056e06326d1eaaa6956d7307a85 (patch) | |
| tree | 23b85f32fb69f85078b3debec08c1353694def6f | |
| parent | eb76b1f6e1697ef170fc45d25e81b21679ea7b0d (diff) | |
Remove Go implementation — ship is skills-only nowmain
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.
| -rw-r--r-- | .gitignore | 20 | ||||
| -rw-r--r-- | Makefile | 11 | ||||
| -rw-r--r-- | SECURITY.md | 55 | ||||
| -rw-r--r-- | SKILLS_PLAN.md | 135 | ||||
| -rw-r--r-- | cmd/ship/commands.go | 365 | ||||
| -rw-r--r-- | cmd/ship/deploy.go | 210 | ||||
| -rw-r--r-- | cmd/ship/deploy_impl.go | 394 | ||||
| -rw-r--r-- | cmd/ship/host.go | 445 | ||||
| -rw-r--r-- | cmd/ship/main.go | 10 | ||||
| -rw-r--r-- | cmd/ship/root.go | 98 | ||||
| -rw-r--r-- | go.mod | 14 | ||||
| -rw-r--r-- | go.sum | 16 | ||||
| -rw-r--r-- | internal/detect/detect.go | 105 | ||||
| -rw-r--r-- | internal/output/output.go | 226 | ||||
| -rw-r--r-- | internal/ssh/client.go | 393 | ||||
| -rw-r--r-- | internal/state/state.go | 106 | ||||
| -rw-r--r-- | internal/templates/templates.go | 358 | ||||
| -rw-r--r-- | templates/app.caddy.tmpl | 3 | ||||
| -rw-r--r-- | templates/service.tmpl | 17 | ||||
| -rw-r--r-- | templates/static.caddy.tmpl | 5 | ||||
| -rw-r--r-- | test/example-website/about.html | 42 | ||||
| -rw-r--r-- | test/example-website/index.html | 46 | ||||
| -rw-r--r-- | test/example-website/style.css | 156 | ||||
| -rw-r--r-- | website/index.html | 216 |
24 files changed, 4 insertions, 3442 deletions
| @@ -1,19 +1,4 @@ | |||
| 1 | # Binaries | 1 | # Editors |
| 2 | /ship | ||
| 3 | /ship-new | ||
| 4 | *.exe | ||
| 5 | *.dll | ||
| 6 | *.so | ||
| 7 | *.dylib | ||
| 8 | bin/ | ||
| 9 | |||
| 10 | # Test binary | ||
| 11 | *.test | ||
| 12 | |||
| 13 | # Go workspace file | ||
| 14 | go.work | ||
| 15 | |||
| 16 | # IDE | ||
| 17 | .vscode/ | 2 | .vscode/ |
| 18 | .idea/ | 3 | .idea/ |
| 19 | *.swp | 4 | *.swp |
| @@ -22,3 +7,6 @@ go.work | |||
| 22 | 7 | ||
| 23 | # Claude local settings | 8 | # Claude local settings |
| 24 | .claude/ | 9 | .claude/ |
| 10 | |||
| 11 | # Syncthing conflict files | ||
| 12 | *.sync-conflict-* | ||
diff --git a/Makefile b/Makefile deleted file mode 100644 index abab996..0000000 --- a/Makefile +++ /dev/null | |||
| @@ -1,11 +0,0 @@ | |||
| 1 | .PHONY: build install deploy-website | ||
| 2 | |||
| 3 | build: | ||
| 4 | go build -o ./bin/ship ./cmd/ship | ||
| 5 | |||
| 6 | install: | ||
| 7 | cp ./bin/ship /usr/local/bin/ | ||
| 8 | |||
| 9 | deploy-website: | ||
| 10 | ship website/ ship.northwest.io | ||
| 11 | |||
diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 2d7a96e..0000000 --- a/SECURITY.md +++ /dev/null | |||
| @@ -1,55 +0,0 @@ | |||
| 1 | # Security Model & Known Gaps | ||
| 2 | |||
| 3 | Ship is a single-user VPS deployment tool. The threat model assumes: | ||
| 4 | - You control the VPS and have root SSH access | ||
| 5 | - You trust everyone who has SSH push access (their keys are copied to the `git` user) | ||
| 6 | - The VPS runs only your own apps | ||
| 7 | |||
| 8 | ## Mitigations in place | ||
| 9 | |||
| 10 | ### App name validation | ||
| 11 | 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. | ||
| 12 | |||
| 13 | ### Scoped sudoers | ||
| 14 | The `git` user's sudo rules are restricted to specific paths: | ||
| 15 | - `systemctl restart/enable` only for services matching `[a-z]*` | ||
| 16 | - `cp` only from `.ship/` subdirectories to `/etc/systemd/system/` and `/etc/caddy/sites-enabled/` | ||
| 17 | - `mkdir` only under `/var/lib/` and `/var/www/` | ||
| 18 | - `chown` only for `git:git` under `/var/lib/` and `/var/www/` | ||
| 19 | |||
| 20 | ### Scoped safe.directory | ||
| 21 | Git's `safe.directory` is set only for the `www-data` user (not system-wide), preserving CVE-2022-24765 protection for other users. | ||
| 22 | |||
| 23 | ## Accepted risks (by design) | ||
| 24 | |||
| 25 | ### SSH key access = root access | ||
| 26 | 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. | ||
| 27 | |||
| 28 | ### Git repo visibility | ||
| 29 | 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. | ||
| 30 | |||
| 31 | ### User-controlled systemd units | ||
| 32 | 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. | ||
| 33 | |||
| 34 | ## Known gaps (not yet addressed) | ||
| 35 | |||
| 36 | ### SSH host key verification disabled | ||
| 37 | `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. | ||
| 38 | |||
| 39 | ### Env files may have loose permissions | ||
| 40 | 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. | ||
| 41 | |||
| 42 | ### host init is not idempotent | ||
| 43 | 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. | ||
| 44 | |||
| 45 | ### No rollback on failed docker build | ||
| 46 | 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. | ||
| 47 | |||
| 48 | ### ship deploy vs git push ownership mismatch | ||
| 49 | `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. | ||
| 50 | |||
| 51 | ### No concurrent push protection | ||
| 52 | Simultaneous pushes can race on the checkout directory and docker build. For single-user usage this is unlikely but not impossible. | ||
| 53 | |||
| 54 | ### Port allocation is monotonic | ||
| 55 | 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 @@ | |||
| 1 | # Ship Skills — Reimagining Ship as Claude Skills | ||
| 2 | |||
| 3 | ## The Idea | ||
| 4 | |||
| 5 | Rather than a monolithic CLI that bakes in rigid assumptions, ship becomes a family of | ||
| 6 | narrow, composable Claude skills. Each skill knows how to do one thing well. Claude | ||
| 7 | provides the reasoning and orchestration. The server is the source of truth. | ||
| 8 | |||
| 9 | Skills are completely generic — no hostnames, app names, or passwords baked in. The | ||
| 10 | same skills work for anyone. Share them with a friend, point them at a different VPS, | ||
| 11 | they just work. | ||
| 12 | |||
| 13 | ## Shared Configuration | ||
| 14 | |||
| 15 | A single static file at `~/.config/ship/config.json` holds the VPS host (and little | ||
| 16 | else). All skills read from this file. No vault dependency — works for anyone. | ||
| 17 | |||
| 18 | ```json | ||
| 19 | { | ||
| 20 | "host": "ubuntu@1.2.3.4", | ||
| 21 | "domain": "example.com" | ||
| 22 | } | ||
| 23 | ``` | ||
| 24 | |||
| 25 | The server itself is the source of truth for everything else — what services are | ||
| 26 | running, what ports are allocated, what Caddy configs exist. No local state file that | ||
| 27 | can go stale. | ||
| 28 | |||
| 29 | ## The Skills | ||
| 30 | |||
| 31 | ### `ship-setup` | ||
| 32 | One-time setup. Asks for VPS host if not configured, saves to `~/.config/ship/config.json`, | ||
| 33 | SSHes in and installs server dependencies (Caddy, directory structure, etc). | ||
| 34 | All other skills depend on this having been run once. | ||
| 35 | |||
| 36 | ### `ship-status` | ||
| 37 | Derives current state entirely from the server at runtime: | ||
| 38 | - Running apps → `systemctl list-units --type=service` | ||
| 39 | - Ports → `/etc/ship/ports/` or env files | ||
| 40 | - Domains → parse Caddy configs in `sites-enabled/` | ||
| 41 | - Static sites → list `/var/www/` | ||
| 42 | |||
| 43 | No state file needed. Always accurate. Replaces the need for any local tracking. | ||
| 44 | |||
| 45 | ### `ship-env` | ||
| 46 | Read and write env vars with merge semantics. Never overwrites — reads existing file | ||
| 47 | first, merges new values on top, writes result. Old vars survive redeployments. | ||
| 48 | |||
| 49 | ### `ship-caddy` | ||
| 50 | Manage per-app Caddyfile config. Knows Caddy syntax. Diffs before writing. Validates | ||
| 51 | before reloading. Never regenerates from scratch — only touches what needs changing. | ||
| 52 | |||
| 53 | ### `ship-service` | ||
| 54 | Systemd management. Handles the difference between a new service (enable + start) and | ||
| 55 | an existing one (restart). Status, logs, restart, stop — all covered. | ||
| 56 | |||
| 57 | ### `ship-binary` | ||
| 58 | Upload and install a pre-built binary. SCP to `/tmp`, move to `/usr/local/bin/`, | ||
| 59 | chmod +x, set up work directory and service user. Calls `ship-service` and `ship-env` | ||
| 60 | to complete the deployment. | ||
| 61 | |||
| 62 | ### `ship-static` | ||
| 63 | Rsync a local dist folder to `/var/www/{name}` on the server. Calls `ship-caddy` to | ||
| 64 | configure serving. | ||
| 65 | |||
| 66 | ### `ship-deploy` | ||
| 67 | A runbook skill that orchestrates the others in the right order for a full deployment. | ||
| 68 | Not imperative code — just a checklist of steps with enough context for Claude to | ||
| 69 | reason about what to do. Adapts based on what the user tells it (binary vs static, | ||
| 70 | what env vars are needed, etc). | ||
| 71 | |||
| 72 | ## What the Server Knows | ||
| 73 | |||
| 74 | All persistent state lives on the server in conventional locations: | ||
| 75 | |||
| 76 | ``` | ||
| 77 | /etc/caddy/sites-enabled/{name}.caddy # per-app Caddy config | ||
| 78 | /etc/ship/env/{name}.env # environment variables | ||
| 79 | /etc/ship/ports/{name} # allocated port number | ||
| 80 | /etc/systemd/system/{name}.service # systemd unit | ||
| 81 | /var/www/{name}/ # static site files | ||
| 82 | /var/lib/{name}/ # app work directory (binary, data) | ||
| 83 | /usr/local/bin/{name} # binary executable | ||
| 84 | ``` | ||
| 85 | |||
| 86 | ## Why This Is Better Than the CLI | ||
| 87 | |||
| 88 | - **Transparent** — Claude tells you what it's about to do before doing it | ||
| 89 | - **Flexible** — no rigid assumptions, Claude reasons about edge cases | ||
| 90 | - **Mergeable** — env files, Caddy configs never blindly overwritten | ||
| 91 | - **Debuggable** — if something goes wrong, just ask Claude to fix it | ||
| 92 | - **Shareable** — no app-specific knowledge baked in, works for anyone | ||
| 93 | - **No stale state** — server is always the source of truth | ||
| 94 | |||
| 95 | ## Per-App Notes (Optional) | ||
| 96 | |||
| 97 | The server can't know things like "this app needs FOODTRACKER_PASSWORD on redeploy" | ||
| 98 | or "this app has SQLite at /var/lib/foodtracker/data/". That's documentation, not | ||
| 99 | state. Users can keep these as plain notes in whatever system they prefer — a vault, | ||
| 100 | a README, a comment in a script. The skills don't depend on it. | ||
| 101 | |||
| 102 | ## SQLite Backup | ||
| 103 | |||
| 104 | Before swapping a binary, `ship-binary` checks `/var/lib/{name}/` for any `.db` files | ||
| 105 | and backs them up to `/var/lib/{name}/backups/{timestamp}.db` before proceeding. Silent | ||
| 106 | and automatic — you never lose data from a bad deploy. | ||
| 107 | |||
| 108 | ## Multi-Host Support | ||
| 109 | |||
| 110 | Config supports multiple named hosts. One is marked as default. All skills use the | ||
| 111 | default unless told otherwise. | ||
| 112 | |||
| 113 | ```json | ||
| 114 | { | ||
| 115 | "default": "prod", | ||
| 116 | "hosts": { | ||
| 117 | "prod": { | ||
| 118 | "host": "ubuntu@1.2.3.4", | ||
| 119 | "domain": "example.com" | ||
| 120 | }, | ||
| 121 | "staging": { | ||
| 122 | "host": "ubuntu@5.6.7.8", | ||
| 123 | "domain": "staging.example.com" | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | ``` | ||
| 128 | |||
| 129 | Usage is natural — "deploy foodtracker to staging" and Claude picks the right host. | ||
| 130 | `ship-setup` can be run multiple times to add new hosts. The default can be changed | ||
| 131 | at any time. | ||
| 132 | |||
| 133 | ## Out of Scope (For Now) | ||
| 134 | |||
| 135 | - 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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strconv" | ||
| 6 | "strings" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/bdw/ship/internal/output" | ||
| 10 | "github.com/bdw/ship/internal/ssh" | ||
| 11 | "github.com/bdw/ship/internal/state" | ||
| 12 | "github.com/spf13/cobra" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // listV2Cmd lists all deployments | ||
| 16 | var listV2Cmd = &cobra.Command{ | ||
| 17 | Use: "list", | ||
| 18 | Short: "List all deployments", | ||
| 19 | RunE: runListV2, | ||
| 20 | } | ||
| 21 | |||
| 22 | func runListV2(cmd *cobra.Command, args []string) error { | ||
| 23 | st, err := state.Load() | ||
| 24 | if err != nil { | ||
| 25 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 26 | } | ||
| 27 | |||
| 28 | hostName := hostFlag | ||
| 29 | if hostName == "" { | ||
| 30 | hostName = st.DefaultHost | ||
| 31 | } | ||
| 32 | if hostName == "" { | ||
| 33 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 34 | } | ||
| 35 | |||
| 36 | hostConfig := st.GetHost(hostName) | ||
| 37 | |||
| 38 | client, err := ssh.Connect(hostName) | ||
| 39 | if err != nil { | ||
| 40 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 41 | } | ||
| 42 | defer client.Close() | ||
| 43 | |||
| 44 | var deploys []output.DeployInfo | ||
| 45 | |||
| 46 | // Get all deployed services by checking /etc/ship/ports and /var/www | ||
| 47 | // Check ports (apps and docker) | ||
| 48 | portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true") | ||
| 49 | for _, name := range strings.Fields(portsOut) { | ||
| 50 | if name == "" { | ||
| 51 | continue | ||
| 52 | } | ||
| 53 | |||
| 54 | // Get actual domain from Caddyfile (first word of first line) | ||
| 55 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 56 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 57 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 58 | domain = d | ||
| 59 | } | ||
| 60 | |||
| 61 | info := output.DeployInfo{ | ||
| 62 | Name: name, | ||
| 63 | URL: fmt.Sprintf("https://%s", domain), | ||
| 64 | } | ||
| 65 | |||
| 66 | // Check if it's docker or binary | ||
| 67 | dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) | ||
| 68 | if strings.Contains(dockerOut, "docker") { | ||
| 69 | info.Type = "docker" | ||
| 70 | } else { | ||
| 71 | info.Type = "binary" | ||
| 72 | } | ||
| 73 | |||
| 74 | // Check if running | ||
| 75 | statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) | ||
| 76 | info.Running = strings.TrimSpace(statusOut) == "active" | ||
| 77 | |||
| 78 | // Check TTL | ||
| 79 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 80 | if ttlOut != "" { | ||
| 81 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 82 | info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | deploys = append(deploys, info) | ||
| 87 | } | ||
| 88 | |||
| 89 | // Check static sites in /var/www | ||
| 90 | wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true") | ||
| 91 | for _, name := range strings.Fields(wwwOut) { | ||
| 92 | if name == "" || name == "html" { | ||
| 93 | continue | ||
| 94 | } | ||
| 95 | |||
| 96 | // Skip if already in ports (would be an app, not static) | ||
| 97 | found := false | ||
| 98 | for _, d := range deploys { | ||
| 99 | if d.Name == name { | ||
| 100 | found = true | ||
| 101 | break | ||
| 102 | } | ||
| 103 | } | ||
| 104 | if found { | ||
| 105 | continue | ||
| 106 | } | ||
| 107 | |||
| 108 | // Get actual domain from Caddyfile | ||
| 109 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 110 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 111 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 112 | domain = d | ||
| 113 | } | ||
| 114 | |||
| 115 | info := output.DeployInfo{ | ||
| 116 | Name: name, | ||
| 117 | URL: fmt.Sprintf("https://%s", domain), | ||
| 118 | Type: "static", | ||
| 119 | Running: true, // Static sites are always "running" | ||
| 120 | } | ||
| 121 | |||
| 122 | // Check TTL | ||
| 123 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 124 | if ttlOut != "" { | ||
| 125 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 126 | info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | deploys = append(deploys, info) | ||
| 131 | } | ||
| 132 | |||
| 133 | output.PrintAndExit(&output.ListResponse{ | ||
| 134 | Status: "ok", | ||
| 135 | Deploys: deploys, | ||
| 136 | }) | ||
| 137 | return nil | ||
| 138 | } | ||
| 139 | |||
| 140 | // statusV2Cmd shows status for a single deployment | ||
| 141 | var statusV2Cmd = &cobra.Command{ | ||
| 142 | Use: "status NAME", | ||
| 143 | Short: "Check status of a deployment", | ||
| 144 | Args: cobra.ExactArgs(1), | ||
| 145 | RunE: runStatusV2, | ||
| 146 | } | ||
| 147 | |||
| 148 | func runStatusV2(cmd *cobra.Command, args []string) error { | ||
| 149 | name := args[0] | ||
| 150 | |||
| 151 | st, err := state.Load() | ||
| 152 | if err != nil { | ||
| 153 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 154 | } | ||
| 155 | |||
| 156 | hostName := hostFlag | ||
| 157 | if hostName == "" { | ||
| 158 | hostName = st.DefaultHost | ||
| 159 | } | ||
| 160 | if hostName == "" { | ||
| 161 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 162 | } | ||
| 163 | |||
| 164 | hostConfig := st.GetHost(hostName) | ||
| 165 | |||
| 166 | client, err := ssh.Connect(hostName) | ||
| 167 | if err != nil { | ||
| 168 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 169 | } | ||
| 170 | defer client.Close() | ||
| 171 | |||
| 172 | // Check if deployment exists | ||
| 173 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 174 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 175 | |||
| 176 | if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { | ||
| 177 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Get actual domain from Caddyfile | ||
| 181 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 182 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 183 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 184 | domain = d | ||
| 185 | } | ||
| 186 | |||
| 187 | resp := &output.StatusResponse{ | ||
| 188 | Status: "ok", | ||
| 189 | Name: name, | ||
| 190 | URL: fmt.Sprintf("https://%s", domain), | ||
| 191 | } | ||
| 192 | |||
| 193 | // Determine type and get details | ||
| 194 | if portOut != "" { | ||
| 195 | port, _ := strconv.Atoi(strings.TrimSpace(portOut)) | ||
| 196 | resp.Port = port | ||
| 197 | |||
| 198 | // Check if docker | ||
| 199 | dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) | ||
| 200 | if strings.Contains(dockerOut, "docker") { | ||
| 201 | resp.Type = "docker" | ||
| 202 | } else { | ||
| 203 | resp.Type = "binary" | ||
| 204 | } | ||
| 205 | |||
| 206 | // Check if running | ||
| 207 | statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) | ||
| 208 | resp.Running = strings.TrimSpace(statusOut) == "active" | ||
| 209 | } else { | ||
| 210 | resp.Type = "static" | ||
| 211 | resp.Running = true | ||
| 212 | } | ||
| 213 | |||
| 214 | // Check TTL | ||
| 215 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 216 | if ttlOut != "" { | ||
| 217 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 218 | resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | output.PrintAndExit(resp) | ||
| 223 | return nil | ||
| 224 | } | ||
| 225 | |||
| 226 | // logsV2Cmd shows logs for a deployment | ||
| 227 | var logsV2Cmd = &cobra.Command{ | ||
| 228 | Use: "logs NAME", | ||
| 229 | Short: "View logs for a deployment", | ||
| 230 | Args: cobra.ExactArgs(1), | ||
| 231 | RunE: runLogsV2, | ||
| 232 | } | ||
| 233 | |||
| 234 | func init() { | ||
| 235 | logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show") | ||
| 236 | } | ||
| 237 | |||
| 238 | func runLogsV2(cmd *cobra.Command, args []string) error { | ||
| 239 | name := args[0] | ||
| 240 | lines, _ := cmd.Flags().GetInt("lines") | ||
| 241 | |||
| 242 | st, err := state.Load() | ||
| 243 | if err != nil { | ||
| 244 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 245 | } | ||
| 246 | |||
| 247 | hostName := hostFlag | ||
| 248 | if hostName == "" { | ||
| 249 | hostName = st.DefaultHost | ||
| 250 | } | ||
| 251 | if hostName == "" { | ||
| 252 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 253 | } | ||
| 254 | |||
| 255 | client, err := ssh.Connect(hostName) | ||
| 256 | if err != nil { | ||
| 257 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 258 | } | ||
| 259 | defer client.Close() | ||
| 260 | |||
| 261 | // Check if it's a static site (no logs) | ||
| 262 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 263 | if strings.TrimSpace(portOut) == "" { | ||
| 264 | // Check if static site exists | ||
| 265 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 266 | if strings.TrimSpace(wwwExists) == "" { | ||
| 267 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 268 | } | ||
| 269 | // Static site - check Caddy access logs | ||
| 270 | 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)) | ||
| 271 | if err != nil { | ||
| 272 | output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) | ||
| 273 | } | ||
| 274 | logLines := strings.Split(strings.TrimSpace(logsOut), "\n") | ||
| 275 | output.PrintAndExit(&output.LogsResponse{ | ||
| 276 | Status: "ok", | ||
| 277 | Name: name, | ||
| 278 | Lines: logLines, | ||
| 279 | }) | ||
| 280 | return nil | ||
| 281 | } | ||
| 282 | |||
| 283 | // Get journalctl logs | ||
| 284 | logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines)) | ||
| 285 | if err != nil { | ||
| 286 | output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) | ||
| 287 | } | ||
| 288 | |||
| 289 | logLines := strings.Split(strings.TrimSpace(logsOut), "\n") | ||
| 290 | |||
| 291 | output.PrintAndExit(&output.LogsResponse{ | ||
| 292 | Status: "ok", | ||
| 293 | Name: name, | ||
| 294 | Lines: logLines, | ||
| 295 | }) | ||
| 296 | return nil | ||
| 297 | } | ||
| 298 | |||
| 299 | // removeV2Cmd removes a deployment | ||
| 300 | var removeV2Cmd = &cobra.Command{ | ||
| 301 | Use: "remove NAME", | ||
| 302 | Short: "Remove a deployment", | ||
| 303 | Args: cobra.ExactArgs(1), | ||
| 304 | RunE: runRemoveV2, | ||
| 305 | } | ||
| 306 | |||
| 307 | func runRemoveV2(cmd *cobra.Command, args []string) error { | ||
| 308 | name := args[0] | ||
| 309 | |||
| 310 | st, err := state.Load() | ||
| 311 | if err != nil { | ||
| 312 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 313 | } | ||
| 314 | |||
| 315 | hostName := hostFlag | ||
| 316 | if hostName == "" { | ||
| 317 | hostName = st.DefaultHost | ||
| 318 | } | ||
| 319 | if hostName == "" { | ||
| 320 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 321 | } | ||
| 322 | |||
| 323 | client, err := ssh.Connect(hostName) | ||
| 324 | if err != nil { | ||
| 325 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 326 | } | ||
| 327 | defer client.Close() | ||
| 328 | |||
| 329 | // Check if deployment exists | ||
| 330 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 331 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 332 | |||
| 333 | if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { | ||
| 334 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 335 | } | ||
| 336 | |||
| 337 | // Stop and disable service | ||
| 338 | client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name)) | ||
| 339 | client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name)) | ||
| 340 | |||
| 341 | // Remove files | ||
| 342 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 343 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 344 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 345 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 346 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 347 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 348 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name)) | ||
| 349 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name)) | ||
| 350 | |||
| 351 | // Remove docker container and image | ||
| 352 | client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name)) | ||
| 353 | client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name)) | ||
| 354 | |||
| 355 | // Reload services | ||
| 356 | client.RunSudo("systemctl daemon-reload") | ||
| 357 | client.RunSudo("systemctl reload caddy") | ||
| 358 | |||
| 359 | output.PrintAndExit(&output.RemoveResponse{ | ||
| 360 | Status: "ok", | ||
| 361 | Name: name, | ||
| 362 | Removed: true, | ||
| 363 | }) | ||
| 364 | return nil | ||
| 365 | } | ||
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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/rand" | ||
| 5 | "encoding/hex" | ||
| 6 | "fmt" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/detect" | ||
| 12 | "github.com/bdw/ship/internal/output" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // deployV2 implements the new agent-first deploy interface. | ||
| 17 | // Usage: ship [PATH] [FLAGS] | ||
| 18 | // PATH defaults to "." if not provided. | ||
| 19 | func deployV2(path string, opts deployV2Options) { | ||
| 20 | start := time.Now() | ||
| 21 | |||
| 22 | // Validate name if provided | ||
| 23 | if opts.Name != "" { | ||
| 24 | if err := validateNameV2(opts.Name); err != nil { | ||
| 25 | output.PrintAndExit(err) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | // Parse TTL if provided | ||
| 30 | var ttlDuration time.Duration | ||
| 31 | if opts.TTL != "" { | ||
| 32 | var err error | ||
| 33 | ttlDuration, err = parseTTL(opts.TTL) | ||
| 34 | if err != nil { | ||
| 35 | output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | // Get host configuration | ||
| 40 | st, err := state.Load() | ||
| 41 | if err != nil { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) | ||
| 43 | } | ||
| 44 | |||
| 45 | hostName := opts.Host | ||
| 46 | if hostName == "" { | ||
| 47 | hostName = st.DefaultHost | ||
| 48 | } | ||
| 49 | if hostName == "" { | ||
| 50 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) | ||
| 51 | } | ||
| 52 | |||
| 53 | hostConfig := st.GetHost(hostName) | ||
| 54 | if hostConfig.BaseDomain == "" { | ||
| 55 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) | ||
| 56 | } | ||
| 57 | |||
| 58 | // Auto-detect project type | ||
| 59 | result := detect.Detect(path) | ||
| 60 | if result.Error != nil { | ||
| 61 | output.PrintAndExit(result.Error) | ||
| 62 | } | ||
| 63 | |||
| 64 | // Generate name if not provided | ||
| 65 | name := opts.Name | ||
| 66 | if name == "" { | ||
| 67 | name = generateName() | ||
| 68 | } | ||
| 69 | |||
| 70 | // Build URL: use custom domain if provided, otherwise use subdomain | ||
| 71 | var url string | ||
| 72 | if opts.Domain != "" { | ||
| 73 | url = fmt.Sprintf("https://%s", opts.Domain) | ||
| 74 | } else { | ||
| 75 | url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) | ||
| 76 | } | ||
| 77 | |||
| 78 | // Build deploy context | ||
| 79 | ctx := &deployContext{ | ||
| 80 | SSHHost: hostName, | ||
| 81 | HostConfig: hostConfig, | ||
| 82 | Name: name, | ||
| 83 | Path: result.Path, | ||
| 84 | URL: url, | ||
| 85 | Opts: opts, | ||
| 86 | } | ||
| 87 | |||
| 88 | // Deploy based on type | ||
| 89 | var deployErr *output.ErrorResponse | ||
| 90 | switch result.Type { | ||
| 91 | case detect.TypeStatic: | ||
| 92 | deployErr = deployStaticV2(ctx) | ||
| 93 | case detect.TypeDocker: | ||
| 94 | deployErr = deployDockerV2(ctx) | ||
| 95 | case detect.TypeBinary: | ||
| 96 | deployErr = deployBinaryV2(ctx) | ||
| 97 | } | ||
| 98 | |||
| 99 | if deployErr != nil { | ||
| 100 | deployErr.Name = name | ||
| 101 | deployErr.URL = url | ||
| 102 | output.PrintAndExit(deployErr) | ||
| 103 | } | ||
| 104 | |||
| 105 | // Set TTL if specified | ||
| 106 | if ttlDuration > 0 { | ||
| 107 | if err := setTTLV2(ctx, ttlDuration); err != nil { | ||
| 108 | // Non-fatal, deploy succeeded | ||
| 109 | // TODO: log warning | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Health check | ||
| 114 | var healthResult *output.HealthResult | ||
| 115 | if opts.Health != "" || result.Type == detect.TypeStatic { | ||
| 116 | endpoint := opts.Health | ||
| 117 | if endpoint == "" { | ||
| 118 | endpoint = "/" | ||
| 119 | } | ||
| 120 | healthResult, deployErr = runHealthCheck(url, endpoint) | ||
| 121 | if deployErr != nil { | ||
| 122 | deployErr.Name = name | ||
| 123 | deployErr.URL = url | ||
| 124 | output.PrintAndExit(deployErr) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | // Build response | ||
| 129 | resp := &output.DeployResponse{ | ||
| 130 | Status: "ok", | ||
| 131 | Name: name, | ||
| 132 | URL: url, | ||
| 133 | Type: string(result.Type), | ||
| 134 | TookMs: time.Since(start).Milliseconds(), | ||
| 135 | Health: healthResult, | ||
| 136 | } | ||
| 137 | |||
| 138 | if ttlDuration > 0 { | ||
| 139 | resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) | ||
| 140 | } | ||
| 141 | |||
| 142 | output.PrintAndExit(resp) | ||
| 143 | } | ||
| 144 | |||
| 145 | type deployV2Options struct { | ||
| 146 | Name string | ||
| 147 | Host string | ||
| 148 | Domain string | ||
| 149 | Health string | ||
| 150 | TTL string | ||
| 151 | Env []string | ||
| 152 | EnvFile string | ||
| 153 | ContainerPort int // Port the container listens on (default 80 for Docker) | ||
| 154 | Pretty bool | ||
| 155 | } | ||
| 156 | |||
| 157 | // deployContext holds all info needed for a deploy | ||
| 158 | type deployContext struct { | ||
| 159 | SSHHost string // SSH connection string (config alias or user@host) | ||
| 160 | HostConfig *state.Host // Host configuration | ||
| 161 | Name string // Deploy name | ||
| 162 | Path string // Local path to deploy | ||
| 163 | URL string // Full URL after deploy | ||
| 164 | Opts deployV2Options | ||
| 165 | } | ||
| 166 | |||
| 167 | // validateNameV2 checks if name matches allowed pattern | ||
| 168 | func validateNameV2(name string) *output.ErrorResponse { | ||
| 169 | // Must be lowercase alphanumeric with hyphens, 1-63 chars | ||
| 170 | pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) | ||
| 171 | if !pattern.MatchString(name) { | ||
| 172 | return output.Err(output.ErrInvalidName, | ||
| 173 | "name must be lowercase alphanumeric with hyphens, 1-63 characters") | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | // generateName creates a random deploy name | ||
| 179 | func generateName() string { | ||
| 180 | bytes := make([]byte, 3) | ||
| 181 | rand.Read(bytes) | ||
| 182 | return "ship-" + hex.EncodeToString(bytes) | ||
| 183 | } | ||
| 184 | |||
| 185 | // parseTTL converts duration strings like "1h", "7d" to time.Duration | ||
| 186 | func parseTTL(s string) (time.Duration, error) { | ||
| 187 | s = strings.TrimSpace(s) | ||
| 188 | if s == "" { | ||
| 189 | return 0, nil | ||
| 190 | } | ||
| 191 | |||
| 192 | // Handle days specially (not supported by time.ParseDuration) | ||
| 193 | if strings.HasSuffix(s, "d") { | ||
| 194 | days := strings.TrimSuffix(s, "d") | ||
| 195 | var d int | ||
| 196 | _, err := fmt.Sscanf(days, "%d", &d) | ||
| 197 | if err != nil { | ||
| 198 | return 0, fmt.Errorf("invalid TTL: %s", s) | ||
| 199 | } | ||
| 200 | return time.Duration(d) * 24 * time.Hour, nil | ||
| 201 | } | ||
| 202 | |||
| 203 | d, err := time.ParseDuration(s) | ||
| 204 | if err != nil { | ||
| 205 | return 0, fmt.Errorf("invalid TTL: %s", s) | ||
| 206 | } | ||
| 207 | return d, nil | ||
| 208 | } | ||
| 209 | |||
| 210 | // 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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "net/http" | ||
| 6 | "strconv" | ||
| 7 | "strings" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/bdw/ship/internal/output" | ||
| 11 | "github.com/bdw/ship/internal/ssh" | ||
| 12 | "github.com/bdw/ship/internal/templates" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // deployStaticV2 deploys a static site | ||
| 16 | // 1. rsync path to /var/www/<name>/ | ||
| 17 | // 2. Generate and upload Caddyfile | ||
| 18 | // 3. Reload Caddy | ||
| 19 | func deployStaticV2(ctx *deployContext) *output.ErrorResponse { | ||
| 20 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 21 | if err != nil { | ||
| 22 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 23 | } | ||
| 24 | defer client.Close() | ||
| 25 | |||
| 26 | name := ctx.Name | ||
| 27 | remotePath := fmt.Sprintf("/var/www/%s", name) | ||
| 28 | |||
| 29 | // Create directory and set ownership for upload | ||
| 30 | user, _ := client.Run("whoami") | ||
| 31 | user = strings.TrimSpace(user) | ||
| 32 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { | ||
| 33 | return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) | ||
| 34 | } | ||
| 35 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { | ||
| 36 | return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) | ||
| 37 | } | ||
| 38 | |||
| 39 | // Upload files using rsync | ||
| 40 | if err := client.UploadDir(ctx.Path, remotePath); err != nil { | ||
| 41 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 42 | } | ||
| 43 | |||
| 44 | // Set ownership back to www-data | ||
| 45 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { | ||
| 46 | // Non-fatal, continue | ||
| 47 | } | ||
| 48 | |||
| 49 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 50 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 51 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 52 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 53 | caddyfile, err := templates.StaticCaddy(map[string]string{ | ||
| 54 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 55 | "RootDir": remotePath, | ||
| 56 | "Name": name, | ||
| 57 | }) | ||
| 58 | if err != nil { | ||
| 59 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 60 | } | ||
| 61 | |||
| 62 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 63 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // Reload Caddy | ||
| 68 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 69 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 70 | } | ||
| 71 | |||
| 72 | return nil | ||
| 73 | } | ||
| 74 | |||
| 75 | // deployDockerV2 deploys a Docker-based app | ||
| 76 | // 1. Allocate port | ||
| 77 | // 2. rsync path to /var/lib/<name>/src/ | ||
| 78 | // 3. docker build | ||
| 79 | // 4. Generate systemd unit and env file | ||
| 80 | // 5. Generate Caddyfile | ||
| 81 | // 6. Start service, reload Caddy | ||
| 82 | func deployDockerV2(ctx *deployContext) *output.ErrorResponse { | ||
| 83 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 84 | if err != nil { | ||
| 85 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 86 | } | ||
| 87 | defer client.Close() | ||
| 88 | |||
| 89 | name := ctx.Name | ||
| 90 | |||
| 91 | // Allocate port on server | ||
| 92 | port, err := allocatePort(client, name) | ||
| 93 | if err != nil { | ||
| 94 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 95 | } | ||
| 96 | |||
| 97 | srcPath := fmt.Sprintf("/var/lib/%s/src", name) | ||
| 98 | dataPath := fmt.Sprintf("/var/lib/%s/data", name) | ||
| 99 | |||
| 100 | // Create directories | ||
| 101 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { | ||
| 102 | return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) | ||
| 103 | } | ||
| 104 | |||
| 105 | // Set ownership for upload | ||
| 106 | user, _ := client.Run("whoami") | ||
| 107 | user = strings.TrimSpace(user) | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { | ||
| 109 | return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) | ||
| 110 | } | ||
| 111 | |||
| 112 | // Upload source | ||
| 113 | if err := client.UploadDir(ctx.Path, srcPath); err != nil { | ||
| 114 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 115 | } | ||
| 116 | |||
| 117 | // Docker build | ||
| 118 | buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) | ||
| 119 | if _, err := client.RunSudo(buildCmd); err != nil { | ||
| 120 | return output.Err(output.ErrBuildFailed, err.Error()) | ||
| 121 | } | ||
| 122 | |||
| 123 | // Determine container port | ||
| 124 | containerPort := ctx.Opts.ContainerPort | ||
| 125 | if containerPort == 0 { | ||
| 126 | containerPort = 80 | ||
| 127 | } | ||
| 128 | |||
| 129 | // Generate and write env file | ||
| 130 | // Use containerPort so the app listens on the correct port inside the container | ||
| 131 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL) | ||
| 132 | for _, e := range ctx.Opts.Env { | ||
| 133 | envContent += e + "\n" | ||
| 134 | } | ||
| 135 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 136 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 137 | // Continue, directory might exist | ||
| 138 | } | ||
| 139 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 140 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 141 | } | ||
| 142 | |||
| 143 | // Generate systemd unit | ||
| 144 | service, err := templates.DockerService(map[string]string{ | ||
| 145 | "Name": name, | ||
| 146 | "Port": strconv.Itoa(port), | ||
| 147 | "ContainerPort": strconv.Itoa(containerPort), | ||
| 148 | }) | ||
| 149 | if err != nil { | ||
| 150 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 151 | } | ||
| 152 | |||
| 153 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 154 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 155 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 156 | } | ||
| 157 | |||
| 158 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 159 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 160 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 161 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 162 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 163 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 164 | "Port": strconv.Itoa(port), | ||
| 165 | }) | ||
| 166 | if err != nil { | ||
| 167 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 168 | } | ||
| 169 | |||
| 170 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 171 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | // Reload systemd and start service | ||
| 176 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 177 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 178 | } | ||
| 179 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 180 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 181 | } | ||
| 182 | |||
| 183 | // Reload Caddy | ||
| 184 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 185 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 186 | } | ||
| 187 | |||
| 188 | return nil | ||
| 189 | } | ||
| 190 | |||
| 191 | // deployBinaryV2 deploys a pre-built binary | ||
| 192 | // 1. Allocate port | ||
| 193 | // 2. scp binary to /usr/local/bin/<name> | ||
| 194 | // 3. Create user for service | ||
| 195 | // 4. Generate systemd unit and env file | ||
| 196 | // 5. Generate Caddyfile | ||
| 197 | // 6. Start service, reload Caddy | ||
| 198 | func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { | ||
| 199 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 200 | if err != nil { | ||
| 201 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 202 | } | ||
| 203 | defer client.Close() | ||
| 204 | |||
| 205 | name := ctx.Name | ||
| 206 | |||
| 207 | // Allocate port on server | ||
| 208 | port, err := allocatePort(client, name) | ||
| 209 | if err != nil { | ||
| 210 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 211 | } | ||
| 212 | |||
| 213 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 214 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 215 | |||
| 216 | // Upload binary | ||
| 217 | if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { | ||
| 218 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 219 | } | ||
| 220 | |||
| 221 | // Move to final location and set permissions | ||
| 222 | if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { | ||
| 223 | return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) | ||
| 224 | } | ||
| 225 | |||
| 226 | // Create work directory | ||
| 227 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 228 | return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) | ||
| 229 | } | ||
| 230 | |||
| 231 | // Create service user (ignore error if exists) | ||
| 232 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) | ||
| 233 | client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) | ||
| 234 | |||
| 235 | // Generate and write env file | ||
| 236 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 237 | for _, e := range ctx.Opts.Env { | ||
| 238 | envContent += e + "\n" | ||
| 239 | } | ||
| 240 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 241 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 242 | // Continue | ||
| 243 | } | ||
| 244 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 245 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 246 | } | ||
| 247 | |||
| 248 | // Generate systemd unit | ||
| 249 | service, err := templates.SystemdService(map[string]string{ | ||
| 250 | "Name": name, | ||
| 251 | "User": name, | ||
| 252 | "WorkDir": workDir, | ||
| 253 | "EnvFile": envPath, | ||
| 254 | "BinaryPath": binaryPath, | ||
| 255 | "Args": "", | ||
| 256 | }) | ||
| 257 | if err != nil { | ||
| 258 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 259 | } | ||
| 260 | |||
| 261 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 262 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 263 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 264 | } | ||
| 265 | |||
| 266 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 267 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 268 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 269 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 270 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 271 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 272 | "Port": strconv.Itoa(port), | ||
| 273 | }) | ||
| 274 | if err != nil { | ||
| 275 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 276 | } | ||
| 277 | |||
| 278 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 279 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | // Reload systemd and start service | ||
| 284 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 285 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 286 | } | ||
| 287 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { | ||
| 288 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 289 | } | ||
| 290 | |||
| 291 | // Reload Caddy | ||
| 292 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 293 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 294 | } | ||
| 295 | |||
| 296 | return nil | ||
| 297 | } | ||
| 298 | |||
| 299 | // allocatePort allocates or retrieves a port for a service | ||
| 300 | // Uses atomic increment on /etc/ship/next_port to avoid collisions | ||
| 301 | func allocatePort(client *ssh.Client, name string) (int, error) { | ||
| 302 | portFile := fmt.Sprintf("/etc/ship/ports/%s", name) | ||
| 303 | |||
| 304 | // Try to read existing port for this app | ||
| 305 | out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) | ||
| 306 | if err == nil && out != "" { | ||
| 307 | out = strings.TrimSpace(out) | ||
| 308 | if port, err := strconv.Atoi(out); err == nil && port > 0 { | ||
| 309 | return port, nil | ||
| 310 | } | ||
| 311 | } | ||
| 312 | |||
| 313 | // Allocate new port atomically using flock | ||
| 314 | // Scans existing port files to avoid collisions even if next_port is stale | ||
| 315 | 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'` | ||
| 316 | out, err = client.RunSudo(allocScript) | ||
| 317 | if err != nil { | ||
| 318 | return 0, fmt.Errorf("failed to allocate port: %w", err) | ||
| 319 | } | ||
| 320 | |||
| 321 | port, err := strconv.Atoi(strings.TrimSpace(out)) | ||
| 322 | if err != nil { | ||
| 323 | return 0, fmt.Errorf("invalid port allocated: %s", out) | ||
| 324 | } | ||
| 325 | |||
| 326 | // Write port allocation for this app | ||
| 327 | if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { | ||
| 328 | return 0, err | ||
| 329 | } | ||
| 330 | |||
| 331 | return port, nil | ||
| 332 | } | ||
| 333 | |||
| 334 | // setTTLV2 sets auto-expiry for a deploy | ||
| 335 | func setTTLV2(ctx *deployContext, ttl time.Duration) error { | ||
| 336 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 337 | if err != nil { | ||
| 338 | return err | ||
| 339 | } | ||
| 340 | defer client.Close() | ||
| 341 | |||
| 342 | expires := time.Now().Add(ttl).Unix() | ||
| 343 | ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) | ||
| 344 | |||
| 345 | if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { | ||
| 346 | return err | ||
| 347 | } | ||
| 348 | |||
| 349 | return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) | ||
| 350 | } | ||
| 351 | |||
| 352 | // runHealthCheck verifies the deploy is responding | ||
| 353 | func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { | ||
| 354 | fullURL := url + endpoint | ||
| 355 | |||
| 356 | // Wait for app to start | ||
| 357 | time.Sleep(2 * time.Second) | ||
| 358 | |||
| 359 | var lastErr error | ||
| 360 | var lastStatus int | ||
| 361 | |||
| 362 | for i := 0; i < 15; i++ { | ||
| 363 | start := time.Now() | ||
| 364 | resp, err := http.Get(fullURL) | ||
| 365 | latency := time.Since(start).Milliseconds() | ||
| 366 | |||
| 367 | if err != nil { | ||
| 368 | lastErr = err | ||
| 369 | time.Sleep(2 * time.Second) | ||
| 370 | continue | ||
| 371 | } | ||
| 372 | resp.Body.Close() | ||
| 373 | lastStatus = resp.StatusCode | ||
| 374 | |||
| 375 | if resp.StatusCode >= 200 && resp.StatusCode < 400 { | ||
| 376 | return &output.HealthResult{ | ||
| 377 | Endpoint: endpoint, | ||
| 378 | Status: resp.StatusCode, | ||
| 379 | LatencyMs: latency, | ||
| 380 | }, nil | ||
| 381 | } | ||
| 382 | |||
| 383 | time.Sleep(2 * time.Second) | ||
| 384 | } | ||
| 385 | |||
| 386 | msg := fmt.Sprintf("health check failed after 30s: ") | ||
| 387 | if lastErr != nil { | ||
| 388 | msg += lastErr.Error() | ||
| 389 | } else { | ||
| 390 | msg += fmt.Sprintf("status %d", lastStatus) | ||
| 391 | } | ||
| 392 | |||
| 393 | return nil, output.Err(output.ErrHealthCheckFailed, msg) | ||
| 394 | } | ||
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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/output" | ||
| 12 | "github.com/bdw/ship/internal/ssh" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | "github.com/bdw/ship/internal/templates" | ||
| 15 | "github.com/spf13/cobra" | ||
| 16 | ) | ||
| 17 | |||
| 18 | func initHostV2() { | ||
| 19 | hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") | ||
| 20 | hostInitV2Cmd.MarkFlagRequired("domain") | ||
| 21 | |||
| 22 | hostV2Cmd.AddCommand(hostInitV2Cmd) | ||
| 23 | hostV2Cmd.AddCommand(hostStatusV2Cmd) | ||
| 24 | } | ||
| 25 | |||
| 26 | var hostInitV2Cmd = &cobra.Command{ | ||
| 27 | Use: "init USER@HOST --domain DOMAIN", | ||
| 28 | Short: "Initialize a VPS for deployments", | ||
| 29 | Long: `Set up a fresh VPS with Caddy, Docker, and required directories. | ||
| 30 | |||
| 31 | Example: | ||
| 32 | ship host init user@my-vps --domain example.com`, | ||
| 33 | Args: cobra.ExactArgs(1), | ||
| 34 | RunE: runHostInitV2, | ||
| 35 | } | ||
| 36 | |||
| 37 | func runHostInitV2(cmd *cobra.Command, args []string) error { | ||
| 38 | host := args[0] | ||
| 39 | domain, _ := cmd.Flags().GetString("domain") | ||
| 40 | |||
| 41 | if domain == "" { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) | ||
| 43 | } | ||
| 44 | |||
| 45 | // Ensure SSH key exists | ||
| 46 | keyPath, pubkey, err := ensureSSHKey() | ||
| 47 | if err != nil { | ||
| 48 | output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) | ||
| 49 | } | ||
| 50 | |||
| 51 | // Try to connect first (to verify key is authorized) | ||
| 52 | client, err := ssh.Connect(host) | ||
| 53 | if err != nil { | ||
| 54 | // Connection failed - provide helpful error with pubkey | ||
| 55 | resp := map[string]interface{}{ | ||
| 56 | "status": "error", | ||
| 57 | "code": "SSH_AUTH_FAILED", | ||
| 58 | "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", | ||
| 59 | "public_key": pubkey, | ||
| 60 | "key_path": keyPath, | ||
| 61 | "host": host, | ||
| 62 | "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), | ||
| 63 | } | ||
| 64 | printJSON(resp) | ||
| 65 | os.Exit(output.ExitSSHFailed) | ||
| 66 | } | ||
| 67 | defer client.Close() | ||
| 68 | |||
| 69 | // Detect OS | ||
| 70 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 71 | if err != nil { | ||
| 72 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) | ||
| 73 | } | ||
| 74 | |||
| 75 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 76 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) | ||
| 77 | } | ||
| 78 | |||
| 79 | var installed []string | ||
| 80 | |||
| 81 | // Install Caddy if needed | ||
| 82 | if _, err := client.Run("which caddy"); err != nil { | ||
| 83 | if err := installCaddyV2(client); err != nil { | ||
| 84 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) | ||
| 85 | } | ||
| 86 | installed = append(installed, "caddy") | ||
| 87 | } | ||
| 88 | |||
| 89 | // Configure Caddy | ||
| 90 | caddyfile := `{ | ||
| 91 | } | ||
| 92 | |||
| 93 | import /etc/caddy/sites-enabled/* | ||
| 94 | ` | ||
| 95 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 96 | output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) | ||
| 97 | } | ||
| 98 | |||
| 99 | // Create directories | ||
| 100 | dirs := []string{ | ||
| 101 | "/etc/ship/env", | ||
| 102 | "/etc/ship/ports", | ||
| 103 | "/etc/ship/ttl", | ||
| 104 | "/etc/caddy/sites-enabled", | ||
| 105 | "/var/www", | ||
| 106 | } | ||
| 107 | for _, dir := range dirs { | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { | ||
| 109 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Install Docker | ||
| 114 | if _, err := client.Run("which docker"); err != nil { | ||
| 115 | if err := installDockerV2(client); err != nil { | ||
| 116 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) | ||
| 117 | } | ||
| 118 | installed = append(installed, "docker") | ||
| 119 | } | ||
| 120 | |||
| 121 | // Install cleanup timer for TTL | ||
| 122 | if err := installCleanupTimer(client); err != nil { | ||
| 123 | // Non-fatal | ||
| 124 | } | ||
| 125 | |||
| 126 | // Enable and start services | ||
| 127 | if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { | ||
| 128 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) | ||
| 129 | } | ||
| 130 | |||
| 131 | // Save state | ||
| 132 | st, err := state.Load() | ||
| 133 | if err != nil { | ||
| 134 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) | ||
| 135 | } | ||
| 136 | |||
| 137 | hostState := st.GetHost(host) | ||
| 138 | hostState.BaseDomain = domain | ||
| 139 | |||
| 140 | if st.GetDefaultHost() == "" { | ||
| 141 | st.SetDefaultHost(host) | ||
| 142 | } | ||
| 143 | |||
| 144 | if err := st.Save(); err != nil { | ||
| 145 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) | ||
| 146 | } | ||
| 147 | |||
| 148 | // Success | ||
| 149 | output.PrintAndExit(&output.HostInitResponse{ | ||
| 150 | Status: "ok", | ||
| 151 | Host: host, | ||
| 152 | Domain: domain, | ||
| 153 | Installed: installed, | ||
| 154 | }) | ||
| 155 | |||
| 156 | return nil | ||
| 157 | } | ||
| 158 | |||
| 159 | func installCaddyV2(client *ssh.Client) error { | ||
| 160 | commands := []string{ | ||
| 161 | "apt-get update", | ||
| 162 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", | ||
| 163 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", | ||
| 164 | "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", | ||
| 165 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", | ||
| 166 | "apt-get update", | ||
| 167 | "apt-get install -y caddy", | ||
| 168 | } | ||
| 169 | |||
| 170 | for _, cmd := range commands { | ||
| 171 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 172 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | func installDockerV2(client *ssh.Client) error { | ||
| 179 | commands := []string{ | ||
| 180 | "apt-get install -y ca-certificates curl gnupg", | ||
| 181 | "install -m 0755 -d /etc/apt/keyrings", | ||
| 182 | "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", | ||
| 183 | "chmod a+r /etc/apt/keyrings/docker.asc", | ||
| 184 | `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'`, | ||
| 185 | "apt-get update", | ||
| 186 | "apt-get install -y docker-ce docker-ce-cli containerd.io", | ||
| 187 | } | ||
| 188 | |||
| 189 | for _, cmd := range commands { | ||
| 190 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 191 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | return nil | ||
| 195 | } | ||
| 196 | |||
| 197 | func installCleanupTimer(client *ssh.Client) error { | ||
| 198 | // Cleanup script | ||
| 199 | script := `#!/bin/bash | ||
| 200 | now=$(date +%s) | ||
| 201 | for f in /etc/ship/ttl/*; do | ||
| 202 | [ -f "$f" ] || continue | ||
| 203 | name=$(basename "$f") | ||
| 204 | expires=$(cat "$f") | ||
| 205 | if [ "$now" -gt "$expires" ]; then | ||
| 206 | systemctl stop "$name" 2>/dev/null || true | ||
| 207 | systemctl disable "$name" 2>/dev/null || true | ||
| 208 | rm -f "/etc/systemd/system/${name}.service" | ||
| 209 | rm -f "/etc/caddy/sites-enabled/${name}.caddy" | ||
| 210 | rm -rf "/var/www/${name}" | ||
| 211 | rm -rf "/var/lib/${name}" | ||
| 212 | rm -f "/usr/local/bin/${name}" | ||
| 213 | rm -f "/etc/ship/env/${name}.env" | ||
| 214 | rm -f "/etc/ship/ports/${name}" | ||
| 215 | rm -f "/etc/ship/ttl/${name}" | ||
| 216 | docker rm -f "$name" 2>/dev/null || true | ||
| 217 | docker rmi "$name" 2>/dev/null || true | ||
| 218 | fi | ||
| 219 | done | ||
| 220 | systemctl daemon-reload | ||
| 221 | systemctl reload caddy | ||
| 222 | ` | ||
| 223 | if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { | ||
| 227 | return err | ||
| 228 | } | ||
| 229 | |||
| 230 | // Timer unit | ||
| 231 | timer := `[Unit] | ||
| 232 | Description=Ship TTL cleanup timer | ||
| 233 | |||
| 234 | [Timer] | ||
| 235 | OnCalendar=hourly | ||
| 236 | Persistent=true | ||
| 237 | |||
| 238 | [Install] | ||
| 239 | WantedBy=timers.target | ||
| 240 | ` | ||
| 241 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { | ||
| 242 | return err | ||
| 243 | } | ||
| 244 | |||
| 245 | // Service unit | ||
| 246 | service := `[Unit] | ||
| 247 | Description=Ship TTL cleanup | ||
| 248 | |||
| 249 | [Service] | ||
| 250 | Type=oneshot | ||
| 251 | ExecStart=/usr/local/bin/ship-cleanup | ||
| 252 | ` | ||
| 253 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { | ||
| 254 | return err | ||
| 255 | } | ||
| 256 | |||
| 257 | // Enable timer | ||
| 258 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 259 | return err | ||
| 260 | } | ||
| 261 | if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { | ||
| 262 | return err | ||
| 263 | } | ||
| 264 | |||
| 265 | return nil | ||
| 266 | } | ||
| 267 | |||
| 268 | var hostStatusV2Cmd = &cobra.Command{ | ||
| 269 | Use: "status", | ||
| 270 | Short: "Check host status", | ||
| 271 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 272 | st, err := state.Load() | ||
| 273 | if err != nil { | ||
| 274 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 275 | } | ||
| 276 | |||
| 277 | hostName := hostFlag | ||
| 278 | if hostName == "" { | ||
| 279 | hostName = st.DefaultHost | ||
| 280 | } | ||
| 281 | if hostName == "" { | ||
| 282 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 283 | } | ||
| 284 | |||
| 285 | hostConfig := st.GetHost(hostName) | ||
| 286 | |||
| 287 | client, err := ssh.Connect(hostName) | ||
| 288 | if err != nil { | ||
| 289 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 290 | } | ||
| 291 | defer client.Close() | ||
| 292 | |||
| 293 | // Check services | ||
| 294 | caddyStatus, _ := client.RunSudo("systemctl is-active caddy") | ||
| 295 | dockerStatus, _ := client.RunSudo("systemctl is-active docker") | ||
| 296 | |||
| 297 | // Print as JSON directly (custom response type) | ||
| 298 | fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", | ||
| 299 | hostName, | ||
| 300 | hostConfig.BaseDomain, | ||
| 301 | strings.TrimSpace(caddyStatus) == "active", | ||
| 302 | strings.TrimSpace(dockerStatus) == "active", | ||
| 303 | ) | ||
| 304 | return nil | ||
| 305 | }, | ||
| 306 | } | ||
| 307 | |||
| 308 | // Preserve git setup functionality from v1 for advanced users | ||
| 309 | func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { | ||
| 310 | // Install git, fcgiwrap, cgit | ||
| 311 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { | ||
| 312 | return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) | ||
| 313 | } | ||
| 314 | |||
| 315 | // Create git user | ||
| 316 | client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") | ||
| 317 | client.RunSudo("usermod -aG docker git") | ||
| 318 | client.RunSudo("usermod -aG git www-data") | ||
| 319 | client.RunSudo("usermod -aG www-data caddy") | ||
| 320 | |||
| 321 | // Copy SSH keys | ||
| 322 | copyKeysCommands := []string{ | ||
| 323 | "mkdir -p /home/git/.ssh", | ||
| 324 | "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", | ||
| 325 | "chown -R git:git /home/git/.ssh", | ||
| 326 | "chmod 700 /home/git/.ssh", | ||
| 327 | "chmod 600 /home/git/.ssh/authorized_keys", | ||
| 328 | } | ||
| 329 | for _, cmd := range copyKeysCommands { | ||
| 330 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 331 | return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) | ||
| 332 | } | ||
| 333 | } | ||
| 334 | |||
| 335 | // Create /srv/git | ||
| 336 | client.RunSudo("mkdir -p /srv/git") | ||
| 337 | client.RunSudo("chown git:git /srv/git") | ||
| 338 | |||
| 339 | // Sudoers | ||
| 340 | sudoersContent := `git ALL=(ALL) NOPASSWD: \ | ||
| 341 | /bin/systemctl daemon-reload, \ | ||
| 342 | /bin/systemctl reload caddy, \ | ||
| 343 | /bin/systemctl restart [a-z]*, \ | ||
| 344 | /bin/systemctl enable [a-z]*, \ | ||
| 345 | /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ | ||
| 346 | /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 347 | /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 348 | /bin/mkdir -p /var/lib/*, \ | ||
| 349 | /bin/mkdir -p /var/www/*, \ | ||
| 350 | /bin/chown -R git\:git /var/lib/*, \ | ||
| 351 | /bin/chown git\:git /var/www/* | ||
| 352 | ` | ||
| 353 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | ||
| 354 | return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) | ||
| 355 | } | ||
| 356 | client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") | ||
| 357 | |||
| 358 | // Vanity import template | ||
| 359 | vanityHTML := `<!DOCTYPE html> | ||
| 360 | <html><head> | ||
| 361 | {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} | ||
| 362 | {{$parts := splitList "/" $path}} | ||
| 363 | {{$module := first $parts}} | ||
| 364 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | ||
| 365 | </head> | ||
| 366 | <body>go get {{.Host}}/{{$module}}</body> | ||
| 367 | </html> | ||
| 368 | ` | ||
| 369 | client.RunSudo("mkdir -p /opt/ship/vanity") | ||
| 370 | client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) | ||
| 371 | |||
| 372 | // cgit config | ||
| 373 | codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) | ||
| 374 | client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) | ||
| 375 | |||
| 376 | cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) | ||
| 377 | client.WriteSudoFile("/etc/cgitrc", cgitrcContent) | ||
| 378 | client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) | ||
| 379 | |||
| 380 | // Start services | ||
| 381 | client.RunSudo("systemctl enable --now fcgiwrap") | ||
| 382 | client.RunSudo("systemctl restart caddy") | ||
| 383 | |||
| 384 | hostState.GitSetup = true | ||
| 385 | return nil | ||
| 386 | } | ||
| 387 | |||
| 388 | // ensureSSHKey checks for an existing SSH key or generates a new one. | ||
| 389 | // Returns the key path, public key contents, and any error. | ||
| 390 | func ensureSSHKey() (keyPath string, pubkey string, err error) { | ||
| 391 | home, err := os.UserHomeDir() | ||
| 392 | if err != nil { | ||
| 393 | return "", "", err | ||
| 394 | } | ||
| 395 | |||
| 396 | // Check common key locations | ||
| 397 | keyPaths := []string{ | ||
| 398 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 399 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 400 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 401 | } | ||
| 402 | |||
| 403 | for _, kp := range keyPaths { | ||
| 404 | pubPath := kp + ".pub" | ||
| 405 | if _, err := os.Stat(kp); err == nil { | ||
| 406 | if _, err := os.Stat(pubPath); err == nil { | ||
| 407 | // Key exists, read public key | ||
| 408 | pub, err := os.ReadFile(pubPath) | ||
| 409 | if err != nil { | ||
| 410 | continue | ||
| 411 | } | ||
| 412 | return kp, strings.TrimSpace(string(pub)), nil | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | // No key found, generate one | ||
| 418 | keyPath = filepath.Join(home, ".ssh", "id_ed25519") | ||
| 419 | sshDir := filepath.Dir(keyPath) | ||
| 420 | |||
| 421 | // Ensure .ssh directory exists | ||
| 422 | if err := os.MkdirAll(sshDir, 0700); err != nil { | ||
| 423 | return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) | ||
| 424 | } | ||
| 425 | |||
| 426 | // Generate key | ||
| 427 | cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") | ||
| 428 | if err := cmd.Run(); err != nil { | ||
| 429 | return "", "", fmt.Errorf("failed to generate SSH key: %w", err) | ||
| 430 | } | ||
| 431 | |||
| 432 | // Read public key | ||
| 433 | pub, err := os.ReadFile(keyPath + ".pub") | ||
| 434 | if err != nil { | ||
| 435 | return "", "", fmt.Errorf("failed to read public key: %w", err) | ||
| 436 | } | ||
| 437 | |||
| 438 | return keyPath, strings.TrimSpace(string(pub)), nil | ||
| 439 | } | ||
| 440 | |||
| 441 | // printJSON outputs a value as JSON to stdout | ||
| 442 | func printJSON(v interface{}) { | ||
| 443 | enc := json.NewEncoder(os.Stdout) | ||
| 444 | enc.Encode(v) | ||
| 445 | } | ||
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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import "os" | ||
| 4 | |||
| 5 | func main() { | ||
| 6 | initV2() | ||
| 7 | if err := rootV2Cmd.Execute(); err != nil { | ||
| 8 | os.Exit(1) | ||
| 9 | } | ||
| 10 | } | ||
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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/output" | ||
| 7 | "github.com/spf13/cobra" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var hostFlag string | ||
| 11 | |||
| 12 | // This file defines the v2 CLI structure. | ||
| 13 | // The primary command is: ship [PATH] [FLAGS] | ||
| 14 | // All output is JSON by default. | ||
| 15 | |||
| 16 | var rootV2Cmd = &cobra.Command{ | ||
| 17 | Use: "ship [PATH]", | ||
| 18 | Short: "Deploy code to a VPS. JSON output for agents.", | ||
| 19 | Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. | ||
| 20 | |||
| 21 | ship ./myproject # auto-detect and deploy | ||
| 22 | ship ./site --name docs # deploy with specific name | ||
| 23 | ship ./api --health /healthz # deploy with health check | ||
| 24 | ship ./preview --ttl 24h # deploy with auto-expiry | ||
| 25 | |||
| 26 | All output is JSON. Use --pretty for human-readable output.`, | ||
| 27 | Args: cobra.MaximumNArgs(1), | ||
| 28 | RunE: runDeployV2, | ||
| 29 | SilenceUsage: true, | ||
| 30 | SilenceErrors: true, | ||
| 31 | DisableAutoGenTag: true, | ||
| 32 | } | ||
| 33 | |||
| 34 | func initV2() { | ||
| 35 | // Global flags | ||
| 36 | rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") | ||
| 37 | rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") | ||
| 38 | |||
| 39 | // Deploy flags | ||
| 40 | rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") | ||
| 41 | rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment") | ||
| 42 | rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") | ||
| 43 | rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") | ||
| 44 | rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") | ||
| 45 | rootV2Cmd.Flags().String("env-file", "", "Path to .env file") | ||
| 46 | rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") | ||
| 47 | |||
| 48 | // Check for SHIP_PRETTY env var | ||
| 49 | if os.Getenv("SHIP_PRETTY") == "1" { | ||
| 50 | output.Pretty = true | ||
| 51 | } | ||
| 52 | |||
| 53 | // Add subcommands | ||
| 54 | rootV2Cmd.AddCommand(listV2Cmd) | ||
| 55 | rootV2Cmd.AddCommand(statusV2Cmd) | ||
| 56 | rootV2Cmd.AddCommand(logsV2Cmd) | ||
| 57 | rootV2Cmd.AddCommand(removeV2Cmd) | ||
| 58 | rootV2Cmd.AddCommand(hostV2Cmd) | ||
| 59 | |||
| 60 | // Initialize host subcommands (from host_v2.go) | ||
| 61 | initHostV2() | ||
| 62 | } | ||
| 63 | |||
| 64 | func runDeployV2(cmd *cobra.Command, args []string) error { | ||
| 65 | path := "." | ||
| 66 | if len(args) > 0 { | ||
| 67 | path = args[0] | ||
| 68 | } | ||
| 69 | |||
| 70 | opts := deployV2Options{ | ||
| 71 | Host: hostFlag, | ||
| 72 | Pretty: output.Pretty, | ||
| 73 | } | ||
| 74 | |||
| 75 | // Get flag values | ||
| 76 | opts.Name, _ = cmd.Flags().GetString("name") | ||
| 77 | opts.Domain, _ = cmd.Flags().GetString("domain") | ||
| 78 | opts.Health, _ = cmd.Flags().GetString("health") | ||
| 79 | opts.TTL, _ = cmd.Flags().GetString("ttl") | ||
| 80 | opts.Env, _ = cmd.Flags().GetStringArray("env") | ||
| 81 | opts.EnvFile, _ = cmd.Flags().GetString("env-file") | ||
| 82 | opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") | ||
| 83 | |||
| 84 | // deployV2 handles all output and exits | ||
| 85 | deployV2(path, opts) | ||
| 86 | |||
| 87 | // Should not reach here (deployV2 calls os.Exit) | ||
| 88 | return nil | ||
| 89 | } | ||
| 90 | |||
| 91 | // Subcommands (list, status, logs, remove) are defined in commands_v2.go | ||
| 92 | |||
| 93 | var hostV2Cmd = &cobra.Command{ | ||
| 94 | Use: "host", | ||
| 95 | Short: "Manage VPS host", | ||
| 96 | } | ||
| 97 | |||
| 98 | // hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go | ||
| @@ -1,14 +0,0 @@ | |||
| 1 | module github.com/bdw/ship | ||
| 2 | |||
| 3 | go 1.21 | ||
| 4 | |||
| 5 | require ( | ||
| 6 | github.com/spf13/cobra v1.10.2 | ||
| 7 | golang.org/x/crypto v0.31.0 | ||
| 8 | ) | ||
| 9 | |||
| 10 | require ( | ||
| 11 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 12 | github.com/spf13/pflag v1.0.9 // indirect | ||
| 13 | golang.org/x/sys v0.28.0 // indirect | ||
| 14 | ) | ||
| @@ -1,16 +0,0 @@ | |||
| 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||
| 2 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||
| 3 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||
| 4 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||
| 5 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= | ||
| 6 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= | ||
| 7 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= | ||
| 8 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||
| 9 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | ||
| 10 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | ||
| 11 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||
| 12 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= | ||
| 13 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||
| 14 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= | ||
| 15 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= | ||
| 16 | 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 @@ | |||
| 1 | // Package detect provides auto-detection of project types. | ||
| 2 | package detect | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | |||
| 8 | "github.com/bdw/ship/internal/output" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // ProjectType represents the detected deployment type | ||
| 12 | type ProjectType string | ||
| 13 | |||
| 14 | const ( | ||
| 15 | TypeStatic ProjectType = "static" | ||
| 16 | TypeDocker ProjectType = "docker" | ||
| 17 | TypeBinary ProjectType = "binary" | ||
| 18 | ) | ||
| 19 | |||
| 20 | // Result is the detection outcome | ||
| 21 | type Result struct { | ||
| 22 | Type ProjectType | ||
| 23 | Path string // Absolute path to deploy | ||
| 24 | Error *output.ErrorResponse | ||
| 25 | } | ||
| 26 | |||
| 27 | // Detect examines a path and determines how to deploy it. | ||
| 28 | // Follows the logic from SPEC.md: | ||
| 29 | // - File: must be executable → binary | ||
| 30 | // - Directory with Dockerfile → docker | ||
| 31 | // - Directory with index.html → static | ||
| 32 | // - Go/Node without Dockerfile → error with guidance | ||
| 33 | // - Empty or unknown → error | ||
| 34 | func Detect(path string) Result { | ||
| 35 | absPath, err := filepath.Abs(path) | ||
| 36 | if err != nil { | ||
| 37 | return Result{Error: output.Err(output.ErrInvalidPath, "cannot resolve path: "+err.Error())} | ||
| 38 | } | ||
| 39 | |||
| 40 | info, err := os.Stat(absPath) | ||
| 41 | if err != nil { | ||
| 42 | if os.IsNotExist(err) { | ||
| 43 | return Result{Error: output.Err(output.ErrInvalidPath, "path does not exist: "+path)} | ||
| 44 | } | ||
| 45 | return Result{Error: output.Err(output.ErrInvalidPath, "cannot access path: "+err.Error())} | ||
| 46 | } | ||
| 47 | |||
| 48 | // File: must be executable binary | ||
| 49 | if !info.IsDir() { | ||
| 50 | return detectFile(absPath, info) | ||
| 51 | } | ||
| 52 | |||
| 53 | // Directory: check contents | ||
| 54 | return detectDirectory(absPath) | ||
| 55 | } | ||
| 56 | |||
| 57 | func detectFile(path string, info os.FileInfo) Result { | ||
| 58 | // Check if executable | ||
| 59 | if info.Mode()&0111 == 0 { | ||
| 60 | return Result{Error: output.Err(output.ErrUnknownProjectType, "file is not executable")} | ||
| 61 | } | ||
| 62 | return Result{Type: TypeBinary, Path: path} | ||
| 63 | } | ||
| 64 | |||
| 65 | func detectDirectory(path string) Result { | ||
| 66 | // Check for Dockerfile first (highest priority) | ||
| 67 | if hasFile(path, "Dockerfile") { | ||
| 68 | return Result{Type: TypeDocker, Path: path} | ||
| 69 | } | ||
| 70 | |||
| 71 | // Check for static site | ||
| 72 | if hasFile(path, "index.html") || hasFile(path, "index.htm") { | ||
| 73 | return Result{Type: TypeStatic, Path: path} | ||
| 74 | } | ||
| 75 | |||
| 76 | // Check for Go project without Dockerfile | ||
| 77 | if hasFile(path, "go.mod") { | ||
| 78 | return Result{Error: output.Err(output.ErrUnknownProjectType, | ||
| 79 | "Go project without Dockerfile. Add a Dockerfile or build a binary first.")} | ||
| 80 | } | ||
| 81 | |||
| 82 | // Check for Node project without Dockerfile | ||
| 83 | if hasFile(path, "package.json") { | ||
| 84 | return Result{Error: output.Err(output.ErrUnknownProjectType, | ||
| 85 | "Node project without Dockerfile. Add a Dockerfile.")} | ||
| 86 | } | ||
| 87 | |||
| 88 | // Check if empty | ||
| 89 | entries, err := os.ReadDir(path) | ||
| 90 | if err != nil { | ||
| 91 | return Result{Error: output.Err(output.ErrInvalidPath, "cannot read directory: "+err.Error())} | ||
| 92 | } | ||
| 93 | if len(entries) == 0 { | ||
| 94 | return Result{Error: output.Err(output.ErrInvalidPath, "directory is empty")} | ||
| 95 | } | ||
| 96 | |||
| 97 | // Unknown | ||
| 98 | return Result{Error: output.Err(output.ErrUnknownProjectType, | ||
| 99 | "cannot detect project type. Add a Dockerfile or index.html.")} | ||
| 100 | } | ||
| 101 | |||
| 102 | func hasFile(dir, name string) bool { | ||
| 103 | _, err := os.Stat(filepath.Join(dir, name)) | ||
| 104 | return err == nil | ||
| 105 | } | ||
diff --git a/internal/output/output.go b/internal/output/output.go deleted file mode 100644 index a9a1036..0000000 --- a/internal/output/output.go +++ /dev/null | |||
| @@ -1,226 +0,0 @@ | |||
| 1 | // Package output provides JSON response types for ship v2. | ||
| 2 | // All commands output JSON by default. Human-readable output is opt-in via --pretty. | ||
| 3 | package output | ||
| 4 | |||
| 5 | import ( | ||
| 6 | "encoding/json" | ||
| 7 | "fmt" | ||
| 8 | "os" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Response is the base interface for all output types | ||
| 12 | type Response interface { | ||
| 13 | IsError() bool | ||
| 14 | } | ||
| 15 | |||
| 16 | // DeployResponse is returned on successful deploy | ||
| 17 | type DeployResponse struct { | ||
| 18 | Status string `json:"status"` | ||
| 19 | Name string `json:"name"` | ||
| 20 | URL string `json:"url"` | ||
| 21 | Type string `json:"type"` // "static", "docker", "binary" | ||
| 22 | TookMs int64 `json:"took_ms"` | ||
| 23 | Health *HealthResult `json:"health,omitempty"` | ||
| 24 | Expires string `json:"expires,omitempty"` // ISO 8601, only if TTL set | ||
| 25 | } | ||
| 26 | |||
| 27 | func (r DeployResponse) IsError() bool { return false } | ||
| 28 | |||
| 29 | // HealthResult is the health check outcome | ||
| 30 | type HealthResult struct { | ||
| 31 | Endpoint string `json:"endpoint"` | ||
| 32 | Status int `json:"status"` | ||
| 33 | LatencyMs int64 `json:"latency_ms"` | ||
| 34 | } | ||
| 35 | |||
| 36 | // ListResponse is returned by ship list | ||
| 37 | type ListResponse struct { | ||
| 38 | Status string `json:"status"` | ||
| 39 | Deploys []DeployInfo `json:"deploys"` | ||
| 40 | } | ||
| 41 | |||
| 42 | func (r ListResponse) IsError() bool { return false } | ||
| 43 | |||
| 44 | // DeployInfo is a single deploy in a list | ||
| 45 | type DeployInfo struct { | ||
| 46 | Name string `json:"name"` | ||
| 47 | URL string `json:"url"` | ||
| 48 | Type string `json:"type"` | ||
| 49 | Running bool `json:"running"` | ||
| 50 | Expires string `json:"expires,omitempty"` | ||
| 51 | } | ||
| 52 | |||
| 53 | // StatusResponse is returned by ship status | ||
| 54 | type StatusResponse struct { | ||
| 55 | Status string `json:"status"` | ||
| 56 | Name string `json:"name"` | ||
| 57 | URL string `json:"url"` | ||
| 58 | Type string `json:"type"` | ||
| 59 | Running bool `json:"running"` | ||
| 60 | Port int `json:"port,omitempty"` | ||
| 61 | Expires string `json:"expires,omitempty"` | ||
| 62 | Memory string `json:"memory,omitempty"` | ||
| 63 | CPU string `json:"cpu,omitempty"` | ||
| 64 | } | ||
| 65 | |||
| 66 | func (r StatusResponse) IsError() bool { return false } | ||
| 67 | |||
| 68 | // LogsResponse is returned by ship logs | ||
| 69 | type LogsResponse struct { | ||
| 70 | Status string `json:"status"` | ||
| 71 | Name string `json:"name"` | ||
| 72 | Lines []string `json:"lines"` | ||
| 73 | } | ||
| 74 | |||
| 75 | func (r LogsResponse) IsError() bool { return false } | ||
| 76 | |||
| 77 | // RemoveResponse is returned by ship remove | ||
| 78 | type RemoveResponse struct { | ||
| 79 | Status string `json:"status"` | ||
| 80 | Name string `json:"name"` | ||
| 81 | Removed bool `json:"removed"` | ||
| 82 | } | ||
| 83 | |||
| 84 | func (r RemoveResponse) IsError() bool { return false } | ||
| 85 | |||
| 86 | // HostInitResponse is returned by ship host init | ||
| 87 | type HostInitResponse struct { | ||
| 88 | Status string `json:"status"` | ||
| 89 | Host string `json:"host"` | ||
| 90 | Domain string `json:"domain"` | ||
| 91 | Installed []string `json:"installed"` | ||
| 92 | } | ||
| 93 | |||
| 94 | func (r HostInitResponse) IsError() bool { return false } | ||
| 95 | |||
| 96 | // ErrorResponse is returned on any failure | ||
| 97 | type ErrorResponse struct { | ||
| 98 | Status string `json:"status"` // always "error" | ||
| 99 | Code string `json:"code"` | ||
| 100 | Message string `json:"message"` | ||
| 101 | Name string `json:"name,omitempty"` | ||
| 102 | URL string `json:"url,omitempty"` | ||
| 103 | } | ||
| 104 | |||
| 105 | func (r ErrorResponse) IsError() bool { return true } | ||
| 106 | |||
| 107 | // Error implements the error interface for compatibility with v1 code | ||
| 108 | func (r *ErrorResponse) Error() string { return r.Message } | ||
| 109 | |||
| 110 | // Error codes | ||
| 111 | const ( | ||
| 112 | ErrInvalidPath = "INVALID_PATH" | ||
| 113 | ErrInvalidArgs = "INVALID_ARGS" | ||
| 114 | ErrUnknownProjectType = "UNKNOWN_PROJECT_TYPE" | ||
| 115 | ErrSSHConnectFailed = "SSH_CONNECT_FAILED" | ||
| 116 | ErrSSHAuthFailed = "SSH_AUTH_FAILED" | ||
| 117 | ErrUploadFailed = "UPLOAD_FAILED" | ||
| 118 | ErrBuildFailed = "BUILD_FAILED" | ||
| 119 | ErrServiceFailed = "SERVICE_FAILED" | ||
| 120 | ErrCaddyFailed = "CADDY_FAILED" | ||
| 121 | ErrHealthCheckFailed = "HEALTH_CHECK_FAILED" | ||
| 122 | ErrHealthCheckTimeout = "HEALTH_CHECK_TIMEOUT" | ||
| 123 | ErrNotFound = "NOT_FOUND" | ||
| 124 | ErrConflict = "CONFLICT" | ||
| 125 | ErrHostNotConfigured = "HOST_NOT_CONFIGURED" | ||
| 126 | ErrInvalidTTL = "INVALID_TTL" | ||
| 127 | ErrInvalidName = "INVALID_NAME" | ||
| 128 | ErrPortExhausted = "PORT_EXHAUSTED" | ||
| 129 | ) | ||
| 130 | |||
| 131 | // Exit codes | ||
| 132 | const ( | ||
| 133 | ExitSuccess = 0 | ||
| 134 | ExitDeployFailed = 1 | ||
| 135 | ExitInvalidArgs = 2 | ||
| 136 | ExitSSHFailed = 3 | ||
| 137 | ExitHealthFailed = 4 | ||
| 138 | ) | ||
| 139 | |||
| 140 | // Pretty controls human-readable output | ||
| 141 | var Pretty bool | ||
| 142 | |||
| 143 | // Print outputs the response as JSON (or pretty if enabled) | ||
| 144 | func Print(r Response) { | ||
| 145 | if Pretty { | ||
| 146 | printPretty(r) | ||
| 147 | return | ||
| 148 | } | ||
| 149 | enc := json.NewEncoder(os.Stdout) | ||
| 150 | enc.Encode(r) | ||
| 151 | } | ||
| 152 | |||
| 153 | // PrintAndExit outputs the response and exits with appropriate code | ||
| 154 | func PrintAndExit(r Response) { | ||
| 155 | Print(r) | ||
| 156 | if r.IsError() { | ||
| 157 | os.Exit(exitCodeForError(r.(*ErrorResponse).Code)) | ||
| 158 | } | ||
| 159 | os.Exit(ExitSuccess) | ||
| 160 | } | ||
| 161 | |||
| 162 | // Err creates an ErrorResponse | ||
| 163 | func Err(code, message string) *ErrorResponse { | ||
| 164 | return &ErrorResponse{ | ||
| 165 | Status: "error", | ||
| 166 | Code: code, | ||
| 167 | Message: message, | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | // ErrWithName creates an ErrorResponse with name context | ||
| 172 | func ErrWithName(code, message, name string) *ErrorResponse { | ||
| 173 | return &ErrorResponse{ | ||
| 174 | Status: "error", | ||
| 175 | Code: code, | ||
| 176 | Message: message, | ||
| 177 | Name: name, | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | func exitCodeForError(code string) int { | ||
| 182 | switch code { | ||
| 183 | case ErrSSHConnectFailed, ErrSSHAuthFailed: | ||
| 184 | return ExitSSHFailed | ||
| 185 | case ErrHealthCheckFailed, ErrHealthCheckTimeout: | ||
| 186 | return ExitHealthFailed | ||
| 187 | case ErrInvalidPath, ErrInvalidTTL, ErrInvalidName, ErrHostNotConfigured, ErrInvalidArgs: | ||
| 188 | return ExitInvalidArgs | ||
| 189 | default: | ||
| 190 | return ExitDeployFailed | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | func printPretty(r Response) { | ||
| 195 | switch v := r.(type) { | ||
| 196 | case *DeployResponse: | ||
| 197 | fmt.Printf("✓ Deployed to %s (%.1fs)\n", v.URL, float64(v.TookMs)/1000) | ||
| 198 | case *ListResponse: | ||
| 199 | if len(v.Deploys) == 0 { | ||
| 200 | fmt.Println("No deployments") | ||
| 201 | return | ||
| 202 | } | ||
| 203 | fmt.Printf("%-20s %-40s %-8s %s\n", "NAME", "URL", "TYPE", "STATUS") | ||
| 204 | for _, d := range v.Deploys { | ||
| 205 | status := "running" | ||
| 206 | if !d.Running { | ||
| 207 | status = "stopped" | ||
| 208 | } | ||
| 209 | if d.Expires != "" { | ||
| 210 | status += " (expires " + d.Expires + ")" | ||
| 211 | } | ||
| 212 | fmt.Printf("%-20s %-40s %-8s %s\n", d.Name, d.URL, d.Type, status) | ||
| 213 | } | ||
| 214 | case *RemoveResponse: | ||
| 215 | fmt.Printf("✓ Removed %s\n", v.Name) | ||
| 216 | case *ErrorResponse: | ||
| 217 | fmt.Printf("✗ %s: %s\n", v.Code, v.Message) | ||
| 218 | case *HostInitResponse: | ||
| 219 | fmt.Printf("✓ Initialized %s with domain %s\n", v.Host, v.Domain) | ||
| 220 | default: | ||
| 221 | // Fallback to JSON | ||
| 222 | enc := json.NewEncoder(os.Stdout) | ||
| 223 | enc.SetIndent("", " ") | ||
| 224 | enc.Encode(r) | ||
| 225 | } | ||
| 226 | } | ||
diff --git a/internal/ssh/client.go b/internal/ssh/client.go deleted file mode 100644 index b9c8d0f..0000000 --- a/internal/ssh/client.go +++ /dev/null | |||
| @@ -1,393 +0,0 @@ | |||
| 1 | package ssh | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "bytes" | ||
| 6 | "fmt" | ||
| 7 | "net" | ||
| 8 | "os" | ||
| 9 | "os/exec" | ||
| 10 | "path/filepath" | ||
| 11 | "strings" | ||
| 12 | |||
| 13 | "golang.org/x/crypto/ssh" | ||
| 14 | "golang.org/x/crypto/ssh/agent" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // Client represents an SSH connection to a remote host | ||
| 18 | type Client struct { | ||
| 19 | host string | ||
| 20 | client *ssh.Client | ||
| 21 | } | ||
| 22 | |||
| 23 | // sshConfig holds SSH configuration for a host | ||
| 24 | type sshConfig struct { | ||
| 25 | Host string | ||
| 26 | HostName string | ||
| 27 | User string | ||
| 28 | Port string | ||
| 29 | IdentityFile string | ||
| 30 | } | ||
| 31 | |||
| 32 | // Connect establishes an SSH connection to the remote host | ||
| 33 | // Supports both SSH config aliases (e.g., "myserver") and user@host format | ||
| 34 | func Connect(host string) (*Client, error) { | ||
| 35 | var user, addr string | ||
| 36 | var identityFile string | ||
| 37 | |||
| 38 | // Try to read SSH config first | ||
| 39 | cfg, err := readSSHConfig(host) | ||
| 40 | if err == nil && cfg.HostName != "" { | ||
| 41 | // Use SSH config | ||
| 42 | user = cfg.User | ||
| 43 | addr = cfg.HostName | ||
| 44 | if cfg.Port != "" { | ||
| 45 | addr = addr + ":" + cfg.Port | ||
| 46 | } else { | ||
| 47 | addr = addr + ":22" | ||
| 48 | } | ||
| 49 | identityFile = cfg.IdentityFile | ||
| 50 | } else { | ||
| 51 | // Fall back to parsing user@host format | ||
| 52 | parts := strings.SplitN(host, "@", 2) | ||
| 53 | if len(parts) != 2 { | ||
| 54 | return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host) | ||
| 55 | } | ||
| 56 | user = parts[0] | ||
| 57 | addr = parts[1] | ||
| 58 | |||
| 59 | // Add default port if not specified | ||
| 60 | if !strings.Contains(addr, ":") { | ||
| 61 | addr = addr + ":22" | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | // Build authentication methods | ||
| 66 | var authMethods []ssh.AuthMethod | ||
| 67 | |||
| 68 | // Try identity file from SSH config first | ||
| 69 | if identityFile != "" { | ||
| 70 | if authMethod, err := publicKeyFromFile(identityFile); err == nil { | ||
| 71 | authMethods = append(authMethods, authMethod) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | // Try SSH agent | ||
| 76 | if authMethod, err := sshAgent(); err == nil { | ||
| 77 | authMethods = append(authMethods, authMethod) | ||
| 78 | } | ||
| 79 | |||
| 80 | // Try default key files | ||
| 81 | if authMethod, err := publicKeyFile(); err == nil { | ||
| 82 | authMethods = append(authMethods, authMethod) | ||
| 83 | } | ||
| 84 | |||
| 85 | if len(authMethods) == 0 { | ||
| 86 | return nil, fmt.Errorf("no SSH authentication method available") | ||
| 87 | } | ||
| 88 | |||
| 89 | config := &ssh.ClientConfig{ | ||
| 90 | User: user, | ||
| 91 | Auth: authMethods, | ||
| 92 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts | ||
| 93 | } | ||
| 94 | |||
| 95 | client, err := ssh.Dial("tcp", addr, config) | ||
| 96 | if err != nil { | ||
| 97 | return nil, fmt.Errorf("failed to connect to %s: %w", host, err) | ||
| 98 | } | ||
| 99 | |||
| 100 | return &Client{ | ||
| 101 | host: host, | ||
| 102 | client: client, | ||
| 103 | }, nil | ||
| 104 | } | ||
| 105 | |||
| 106 | // Close closes the SSH connection | ||
| 107 | func (c *Client) Close() error { | ||
| 108 | return c.client.Close() | ||
| 109 | } | ||
| 110 | |||
| 111 | // Run executes a command on the remote host and returns the output | ||
| 112 | func (c *Client) Run(cmd string) (string, error) { | ||
| 113 | session, err := c.client.NewSession() | ||
| 114 | if err != nil { | ||
| 115 | return "", err | ||
| 116 | } | ||
| 117 | defer session.Close() | ||
| 118 | |||
| 119 | var stdout, stderr bytes.Buffer | ||
| 120 | session.Stdout = &stdout | ||
| 121 | session.Stderr = &stderr | ||
| 122 | |||
| 123 | if err := session.Run(cmd); err != nil { | ||
| 124 | return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String()) | ||
| 125 | } | ||
| 126 | |||
| 127 | return stdout.String(), nil | ||
| 128 | } | ||
| 129 | |||
| 130 | // RunSudo executes a command with sudo on the remote host | ||
| 131 | func (c *Client) RunSudo(cmd string) (string, error) { | ||
| 132 | return c.Run("sudo " + cmd) | ||
| 133 | } | ||
| 134 | |||
| 135 | // RunSudoStream executes a command with sudo and streams output to stdout/stderr | ||
| 136 | func (c *Client) RunSudoStream(cmd string) error { | ||
| 137 | session, err := c.client.NewSession() | ||
| 138 | if err != nil { | ||
| 139 | return err | ||
| 140 | } | ||
| 141 | defer session.Close() | ||
| 142 | |||
| 143 | session.Stdout = os.Stdout | ||
| 144 | session.Stderr = os.Stderr | ||
| 145 | |||
| 146 | if err := session.Run("sudo " + cmd); err != nil { | ||
| 147 | return fmt.Errorf("command failed: %w", err) | ||
| 148 | } | ||
| 149 | |||
| 150 | return nil | ||
| 151 | } | ||
| 152 | |||
| 153 | // RunStream executes a command and streams output to stdout/stderr | ||
| 154 | func (c *Client) RunStream(cmd string) error { | ||
| 155 | session, err := c.client.NewSession() | ||
| 156 | if err != nil { | ||
| 157 | return err | ||
| 158 | } | ||
| 159 | defer session.Close() | ||
| 160 | |||
| 161 | session.Stdout = os.Stdout | ||
| 162 | session.Stderr = os.Stderr | ||
| 163 | |||
| 164 | if err := session.Run(cmd); err != nil { | ||
| 165 | return fmt.Errorf("command failed: %w", err) | ||
| 166 | } | ||
| 167 | |||
| 168 | return nil | ||
| 169 | } | ||
| 170 | |||
| 171 | // Upload copies a local file to the remote host using scp | ||
| 172 | func (c *Client) Upload(localPath, remotePath string) error { | ||
| 173 | // Use external scp command for simplicity | ||
| 174 | // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath | ||
| 175 | cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath) | ||
| 176 | |||
| 177 | var stderr bytes.Buffer | ||
| 178 | cmd.Stderr = &stderr | ||
| 179 | |||
| 180 | if err := cmd.Run(); err != nil { | ||
| 181 | return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String()) | ||
| 182 | } | ||
| 183 | |||
| 184 | return nil | ||
| 185 | } | ||
| 186 | |||
| 187 | // UploadDir copies a local directory to the remote host using rsync | ||
| 188 | func (c *Client) UploadDir(localDir, remoteDir string) error { | ||
| 189 | // Use rsync for directory uploads | ||
| 190 | // Format: rsync -avz --delete localDir/ user@host:remoteDir/ | ||
| 191 | localDir = strings.TrimSuffix(localDir, "/") + "/" | ||
| 192 | remoteDir = strings.TrimSuffix(remoteDir, "/") + "/" | ||
| 193 | |||
| 194 | cmd := exec.Command("rsync", "-avz", "--delete", | ||
| 195 | "-e", "ssh -o StrictHostKeyChecking=no", | ||
| 196 | localDir, c.host+":"+remoteDir) | ||
| 197 | |||
| 198 | var stderr bytes.Buffer | ||
| 199 | cmd.Stderr = &stderr | ||
| 200 | |||
| 201 | if err := cmd.Run(); err != nil { | ||
| 202 | return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String()) | ||
| 203 | } | ||
| 204 | |||
| 205 | return nil | ||
| 206 | } | ||
| 207 | |||
| 208 | // WriteFile creates a file with the given content on the remote host | ||
| 209 | func (c *Client) WriteFile(remotePath, content string) error { | ||
| 210 | session, err := c.client.NewSession() | ||
| 211 | if err != nil { | ||
| 212 | return err | ||
| 213 | } | ||
| 214 | defer session.Close() | ||
| 215 | |||
| 216 | // Use cat to write content to file | ||
| 217 | cmd := fmt.Sprintf("cat > %s", remotePath) | ||
| 218 | session.Stdin = strings.NewReader(content) | ||
| 219 | |||
| 220 | var stderr bytes.Buffer | ||
| 221 | session.Stderr = &stderr | ||
| 222 | |||
| 223 | if err := session.Run(cmd); err != nil { | ||
| 224 | return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String()) | ||
| 225 | } | ||
| 226 | |||
| 227 | return nil | ||
| 228 | } | ||
| 229 | |||
| 230 | // WriteSudoFile creates a file with the given content using sudo | ||
| 231 | func (c *Client) WriteSudoFile(remotePath, content string) error { | ||
| 232 | session, err := c.client.NewSession() | ||
| 233 | if err != nil { | ||
| 234 | return err | ||
| 235 | } | ||
| 236 | defer session.Close() | ||
| 237 | |||
| 238 | // Use sudo tee to write content to file | ||
| 239 | cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath) | ||
| 240 | session.Stdin = strings.NewReader(content) | ||
| 241 | |||
| 242 | var stderr bytes.Buffer | ||
| 243 | session.Stderr = &stderr | ||
| 244 | |||
| 245 | if err := session.Run(cmd); err != nil { | ||
| 246 | return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String()) | ||
| 247 | } | ||
| 248 | |||
| 249 | return nil | ||
| 250 | } | ||
| 251 | |||
| 252 | // readSSHConfig reads and parses the SSH config file for a given host | ||
| 253 | func readSSHConfig(host string) (*sshConfig, error) { | ||
| 254 | home, err := os.UserHomeDir() | ||
| 255 | if err != nil { | ||
| 256 | return nil, err | ||
| 257 | } | ||
| 258 | |||
| 259 | configPath := filepath.Join(home, ".ssh", "config") | ||
| 260 | file, err := os.Open(configPath) | ||
| 261 | if err != nil { | ||
| 262 | return nil, err | ||
| 263 | } | ||
| 264 | defer file.Close() | ||
| 265 | |||
| 266 | cfg := &sshConfig{} | ||
| 267 | var currentHost string | ||
| 268 | var matchedHost bool | ||
| 269 | |||
| 270 | scanner := bufio.NewScanner(file) | ||
| 271 | for scanner.Scan() { | ||
| 272 | line := strings.TrimSpace(scanner.Text()) | ||
| 273 | |||
| 274 | // Skip comments and empty lines | ||
| 275 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 276 | continue | ||
| 277 | } | ||
| 278 | |||
| 279 | fields := strings.Fields(line) | ||
| 280 | if len(fields) < 2 { | ||
| 281 | continue | ||
| 282 | } | ||
| 283 | |||
| 284 | key := strings.ToLower(fields[0]) | ||
| 285 | value := fields[1] | ||
| 286 | |||
| 287 | // Expand ~ in paths | ||
| 288 | if strings.HasPrefix(value, "~/") { | ||
| 289 | value = filepath.Join(home, value[2:]) | ||
| 290 | } | ||
| 291 | |||
| 292 | switch key { | ||
| 293 | case "host": | ||
| 294 | currentHost = value | ||
| 295 | if currentHost == host { | ||
| 296 | matchedHost = true | ||
| 297 | cfg.Host = host | ||
| 298 | } else { | ||
| 299 | matchedHost = false | ||
| 300 | } | ||
| 301 | case "hostname": | ||
| 302 | if matchedHost { | ||
| 303 | cfg.HostName = value | ||
| 304 | } | ||
| 305 | case "user": | ||
| 306 | if matchedHost { | ||
| 307 | cfg.User = value | ||
| 308 | } | ||
| 309 | case "port": | ||
| 310 | if matchedHost { | ||
| 311 | cfg.Port = value | ||
| 312 | } | ||
| 313 | case "identityfile": | ||
| 314 | if matchedHost { | ||
| 315 | cfg.IdentityFile = value | ||
| 316 | } | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | if err := scanner.Err(); err != nil { | ||
| 321 | return nil, err | ||
| 322 | } | ||
| 323 | |||
| 324 | if cfg.Host == "" { | ||
| 325 | return nil, fmt.Errorf("host %s not found in SSH config", host) | ||
| 326 | } | ||
| 327 | |||
| 328 | return cfg, nil | ||
| 329 | } | ||
| 330 | |||
| 331 | // sshAgent returns an auth method using SSH agent | ||
| 332 | func sshAgent() (ssh.AuthMethod, error) { | ||
| 333 | socket := os.Getenv("SSH_AUTH_SOCK") | ||
| 334 | if socket == "" { | ||
| 335 | return nil, fmt.Errorf("SSH_AUTH_SOCK not set") | ||
| 336 | } | ||
| 337 | |||
| 338 | conn, err := net.Dial("unix", socket) | ||
| 339 | if err != nil { | ||
| 340 | return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) | ||
| 341 | } | ||
| 342 | |||
| 343 | agentClient := agent.NewClient(conn) | ||
| 344 | return ssh.PublicKeysCallback(agentClient.Signers), nil | ||
| 345 | } | ||
| 346 | |||
| 347 | // publicKeyFromFile returns an auth method from a specific private key file | ||
| 348 | func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) { | ||
| 349 | key, err := os.ReadFile(keyPath) | ||
| 350 | if err != nil { | ||
| 351 | return nil, err | ||
| 352 | } | ||
| 353 | |||
| 354 | signer, err := ssh.ParsePrivateKey(key) | ||
| 355 | if err != nil { | ||
| 356 | return nil, err | ||
| 357 | } | ||
| 358 | |||
| 359 | return ssh.PublicKeys(signer), nil | ||
| 360 | } | ||
| 361 | |||
| 362 | // publicKeyFile returns an auth method using a private key file | ||
| 363 | func publicKeyFile() (ssh.AuthMethod, error) { | ||
| 364 | home, err := os.UserHomeDir() | ||
| 365 | if err != nil { | ||
| 366 | return nil, err | ||
| 367 | } | ||
| 368 | |||
| 369 | // Try common key locations | ||
| 370 | keyPaths := []string{ | ||
| 371 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 372 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 373 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 374 | } | ||
| 375 | |||
| 376 | for _, keyPath := range keyPaths { | ||
| 377 | if _, err := os.Stat(keyPath); err == nil { | ||
| 378 | key, err := os.ReadFile(keyPath) | ||
| 379 | if err != nil { | ||
| 380 | continue | ||
| 381 | } | ||
| 382 | |||
| 383 | signer, err := ssh.ParsePrivateKey(key) | ||
| 384 | if err != nil { | ||
| 385 | continue | ||
| 386 | } | ||
| 387 | |||
| 388 | return ssh.PublicKeys(signer), nil | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | return nil, fmt.Errorf("no SSH private key found") | ||
| 393 | } | ||
diff --git a/internal/state/state.go b/internal/state/state.go deleted file mode 100644 index 9b06179..0000000 --- a/internal/state/state.go +++ /dev/null | |||
| @@ -1,106 +0,0 @@ | |||
| 1 | package state | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "regexp" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // State represents the local ship configuration | ||
| 12 | type State struct { | ||
| 13 | DefaultHost string `json:"default_host,omitempty"` | ||
| 14 | Hosts map[string]*Host `json:"hosts"` | ||
| 15 | } | ||
| 16 | |||
| 17 | // Host represents configuration for a single VPS | ||
| 18 | type Host struct { | ||
| 19 | BaseDomain string `json:"base_domain,omitempty"` | ||
| 20 | GitSetup bool `json:"git_setup,omitempty"` | ||
| 21 | } | ||
| 22 | |||
| 23 | var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) | ||
| 24 | |||
| 25 | // ValidateName checks that a name is safe for use in shell commands, | ||
| 26 | // file paths, systemd units, and DNS labels. | ||
| 27 | func ValidateName(name string) error { | ||
| 28 | if !validName.MatchString(name) { | ||
| 29 | return fmt.Errorf("invalid name %q: must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and be 1-63 characters", name) | ||
| 30 | } | ||
| 31 | return nil | ||
| 32 | } | ||
| 33 | |||
| 34 | // Load reads state from ~/.config/ship/state.json | ||
| 35 | func Load() (*State, error) { | ||
| 36 | path := statePath() | ||
| 37 | |||
| 38 | if _, err := os.Stat(path); os.IsNotExist(err) { | ||
| 39 | return &State{ | ||
| 40 | Hosts: make(map[string]*Host), | ||
| 41 | }, nil | ||
| 42 | } | ||
| 43 | |||
| 44 | data, err := os.ReadFile(path) | ||
| 45 | if err != nil { | ||
| 46 | return nil, fmt.Errorf("failed to read state file: %w", err) | ||
| 47 | } | ||
| 48 | |||
| 49 | var state State | ||
| 50 | if err := json.Unmarshal(data, &state); err != nil { | ||
| 51 | return nil, fmt.Errorf("failed to parse state file: %w", err) | ||
| 52 | } | ||
| 53 | |||
| 54 | if state.Hosts == nil { | ||
| 55 | state.Hosts = make(map[string]*Host) | ||
| 56 | } | ||
| 57 | |||
| 58 | return &state, nil | ||
| 59 | } | ||
| 60 | |||
| 61 | // Save writes state to ~/.config/ship/state.json | ||
| 62 | func (s *State) Save() error { | ||
| 63 | path := statePath() | ||
| 64 | |||
| 65 | dir := filepath.Dir(path) | ||
| 66 | if err := os.MkdirAll(dir, 0755); err != nil { | ||
| 67 | return fmt.Errorf("failed to create config directory: %w", err) | ||
| 68 | } | ||
| 69 | |||
| 70 | data, err := json.MarshalIndent(s, "", " ") | ||
| 71 | if err != nil { | ||
| 72 | return fmt.Errorf("failed to marshal state: %w", err) | ||
| 73 | } | ||
| 74 | |||
| 75 | if err := os.WriteFile(path, data, 0600); err != nil { | ||
| 76 | return fmt.Errorf("failed to write state file: %w", err) | ||
| 77 | } | ||
| 78 | |||
| 79 | return nil | ||
| 80 | } | ||
| 81 | |||
| 82 | // GetHost returns the host config, creating it if it doesn't exist | ||
| 83 | func (s *State) GetHost(host string) *Host { | ||
| 84 | if s.Hosts[host] == nil { | ||
| 85 | s.Hosts[host] = &Host{} | ||
| 86 | } | ||
| 87 | return s.Hosts[host] | ||
| 88 | } | ||
| 89 | |||
| 90 | // GetDefaultHost returns the default host, or empty string if not set | ||
| 91 | func (s *State) GetDefaultHost() string { | ||
| 92 | return s.DefaultHost | ||
| 93 | } | ||
| 94 | |||
| 95 | // SetDefaultHost sets the default host | ||
| 96 | func (s *State) SetDefaultHost(host string) { | ||
| 97 | s.DefaultHost = host | ||
| 98 | } | ||
| 99 | |||
| 100 | func statePath() string { | ||
| 101 | home, err := os.UserHomeDir() | ||
| 102 | if err != nil { | ||
| 103 | return ".ship-state.json" | ||
| 104 | } | ||
| 105 | return filepath.Join(home, ".config", "ship", "state.json") | ||
| 106 | } | ||
diff --git a/internal/templates/templates.go b/internal/templates/templates.go deleted file mode 100644 index 2163f47..0000000 --- a/internal/templates/templates.go +++ /dev/null | |||
| @@ -1,358 +0,0 @@ | |||
| 1 | package templates | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "text/template" | ||
| 6 | ) | ||
| 7 | |||
| 8 | var serviceTemplate = `[Unit] | ||
| 9 | Description={{.Name}} | ||
| 10 | After=network.target | ||
| 11 | |||
| 12 | [Service] | ||
| 13 | Type=simple | ||
| 14 | User={{.User}} | ||
| 15 | WorkingDirectory={{.WorkDir}} | ||
| 16 | EnvironmentFile={{.EnvFile}} | ||
| 17 | ExecStart={{.BinaryPath}} {{.Args}} | ||
| 18 | Restart=always | ||
| 19 | RestartSec=5s | ||
| 20 | NoNewPrivileges=true | ||
| 21 | PrivateTmp=true | ||
| 22 | {{- if .Memory}} | ||
| 23 | MemoryMax={{.Memory}} | ||
| 24 | {{- end}} | ||
| 25 | {{- if .CPU}} | ||
| 26 | CPUQuota={{.CPU}} | ||
| 27 | {{- end}} | ||
| 28 | |||
| 29 | [Install] | ||
| 30 | WantedBy=multi-user.target | ||
| 31 | ` | ||
| 32 | |||
| 33 | var appCaddyTemplate = `{{.Domain}} { | ||
| 34 | reverse_proxy 127.0.0.1:{{.Port}} | ||
| 35 | } | ||
| 36 | ` | ||
| 37 | |||
| 38 | var staticCaddyTemplate = `{{.Domain}} { | ||
| 39 | root * {{.RootDir}} | ||
| 40 | file_server | ||
| 41 | encode gzip | ||
| 42 | } | ||
| 43 | ` | ||
| 44 | |||
| 45 | // SystemdService generates a systemd service unit file | ||
| 46 | func SystemdService(data map[string]string) (string, error) { | ||
| 47 | tmpl, err := template.New("service").Parse(serviceTemplate) | ||
| 48 | if err != nil { | ||
| 49 | return "", err | ||
| 50 | } | ||
| 51 | |||
| 52 | var buf bytes.Buffer | ||
| 53 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 54 | return "", err | ||
| 55 | } | ||
| 56 | |||
| 57 | return buf.String(), nil | ||
| 58 | } | ||
| 59 | |||
| 60 | // AppCaddy generates a Caddy config for a Go app | ||
| 61 | func AppCaddy(data map[string]string) (string, error) { | ||
| 62 | tmpl, err := template.New("caddy").Parse(appCaddyTemplate) | ||
| 63 | if err != nil { | ||
| 64 | return "", err | ||
| 65 | } | ||
| 66 | |||
| 67 | var buf bytes.Buffer | ||
| 68 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 69 | return "", err | ||
| 70 | } | ||
| 71 | |||
| 72 | return buf.String(), nil | ||
| 73 | } | ||
| 74 | |||
| 75 | // StaticCaddy generates a Caddy config for a static site | ||
| 76 | func StaticCaddy(data map[string]string) (string, error) { | ||
| 77 | tmpl, err := template.New("caddy").Parse(staticCaddyTemplate) | ||
| 78 | if err != nil { | ||
| 79 | return "", err | ||
| 80 | } | ||
| 81 | |||
| 82 | var buf bytes.Buffer | ||
| 83 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 84 | return "", err | ||
| 85 | } | ||
| 86 | |||
| 87 | return buf.String(), nil | ||
| 88 | } | ||
| 89 | |||
| 90 | var postReceiveHookTemplate = `#!/bin/bash | ||
| 91 | set -euo pipefail | ||
| 92 | |||
| 93 | REPO=/srv/git/{{.Name}}.git | ||
| 94 | SRC=/var/lib/{{.Name}}/src | ||
| 95 | NAME={{.Name}} | ||
| 96 | |||
| 97 | while read oldrev newrev refname; do | ||
| 98 | branch=$(git rev-parse --symbolic --abbrev-ref "$refname") | ||
| 99 | [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; } | ||
| 100 | done | ||
| 101 | |||
| 102 | # Ensure checkout directory exists | ||
| 103 | sudo /bin/mkdir -p "$SRC" | ||
| 104 | sudo /bin/chown -R git:git "/var/lib/${NAME}" | ||
| 105 | |||
| 106 | echo "==> Checking out code..." | ||
| 107 | git --work-tree="$SRC" --git-dir="$REPO" checkout -f main | ||
| 108 | |||
| 109 | cd "$SRC" | ||
| 110 | |||
| 111 | # If no Dockerfile, nothing to deploy | ||
| 112 | if [ ! -f Dockerfile ]; then | ||
| 113 | echo "No Dockerfile found, skipping deploy." | ||
| 114 | exit 0 | ||
| 115 | fi | ||
| 116 | |||
| 117 | # Install deployment config from repo (using full paths for sudoers) | ||
| 118 | if [ -f "$SRC/.ship/service" ]; then | ||
| 119 | echo "==> Installing systemd unit..." | ||
| 120 | sudo /bin/cp "$SRC/.ship/service" "/etc/systemd/system/${NAME}.service" | ||
| 121 | sudo systemctl daemon-reload | ||
| 122 | fi | ||
| 123 | if [ -f "$SRC/.ship/Caddyfile" ]; then | ||
| 124 | echo "==> Installing Caddy config..." | ||
| 125 | sudo /bin/cp "$SRC/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy" | ||
| 126 | sudo systemctl reload caddy | ||
| 127 | fi | ||
| 128 | |||
| 129 | # Ensure data directory exists | ||
| 130 | sudo /bin/mkdir -p "/var/lib/${NAME}/data" | ||
| 131 | sudo /bin/chown -R git:git "/var/lib/${NAME}/data" | ||
| 132 | |||
| 133 | echo "==> Building Docker image..." | ||
| 134 | docker build -t ${NAME}:latest . | ||
| 135 | |||
| 136 | echo "==> Restarting service..." | ||
| 137 | sudo systemctl restart ${NAME} | ||
| 138 | |||
| 139 | echo "==> Deploy complete!" | ||
| 140 | ` | ||
| 141 | |||
| 142 | var postReceiveHookStaticTemplate = `#!/bin/bash | ||
| 143 | set -euo pipefail | ||
| 144 | |||
| 145 | REPO=/srv/git/{{.Name}}.git | ||
| 146 | WEBROOT=/var/www/{{.Name}} | ||
| 147 | NAME={{.Name}} | ||
| 148 | |||
| 149 | while read oldrev newrev refname; do | ||
| 150 | branch=$(git rev-parse --symbolic --abbrev-ref "$refname") | ||
| 151 | [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; } | ||
| 152 | done | ||
| 153 | |||
| 154 | echo "==> Deploying static site..." | ||
| 155 | git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main | ||
| 156 | |||
| 157 | if [ -f "$WEBROOT/.ship/Caddyfile" ]; then | ||
| 158 | echo "==> Installing Caddy config..." | ||
| 159 | sudo /bin/cp "$WEBROOT/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy" | ||
| 160 | sudo systemctl reload caddy | ||
| 161 | fi | ||
| 162 | |||
| 163 | echo "==> Deploy complete!" | ||
| 164 | ` | ||
| 165 | |||
| 166 | var codeCaddyTemplate = `{{.BaseDomain}} { | ||
| 167 | @goget query go-get=1 | ||
| 168 | handle @goget { | ||
| 169 | root * /opt/ship/vanity | ||
| 170 | templates | ||
| 171 | rewrite * /index.html | ||
| 172 | file_server | ||
| 173 | } | ||
| 174 | |||
| 175 | @git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$" | ||
| 176 | handle @git { | ||
| 177 | reverse_proxy unix//run/fcgiwrap.socket { | ||
| 178 | transport fastcgi { | ||
| 179 | env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend | ||
| 180 | env GIT_PROJECT_ROOT /srv/git | ||
| 181 | env REQUEST_METHOD {method} | ||
| 182 | env QUERY_STRING {query} | ||
| 183 | env PATH_INFO {path} | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | @cgitassets path /cgit/* | ||
| 189 | handle @cgitassets { | ||
| 190 | root * /usr/share/cgit | ||
| 191 | uri strip_prefix /cgit | ||
| 192 | file_server | ||
| 193 | } | ||
| 194 | |||
| 195 | handle { | ||
| 196 | reverse_proxy unix//run/fcgiwrap.socket { | ||
| 197 | transport fastcgi { | ||
| 198 | env SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi | ||
| 199 | env QUERY_STRING {query} | ||
| 200 | env REQUEST_METHOD {method} | ||
| 201 | env PATH_INFO {path} | ||
| 202 | env HTTP_HOST {host} | ||
| 203 | env SERVER_NAME {host} | ||
| 204 | } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | ` | ||
| 209 | |||
| 210 | var dockerServiceTemplate = `[Unit] | ||
| 211 | Description={{.Name}} | ||
| 212 | After=network.target docker.service | ||
| 213 | Requires=docker.service | ||
| 214 | |||
| 215 | [Service] | ||
| 216 | Type=simple | ||
| 217 | ExecStartPre=-/usr/bin/docker rm -f {{.Name}} | ||
| 218 | ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ | ||
| 219 | -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \ | ||
| 220 | --env-file /etc/ship/env/{{.Name}}.env \ | ||
| 221 | -v /var/lib/{{.Name}}/data:/data \ | ||
| 222 | {{.Name}}:latest | ||
| 223 | ExecStop=/usr/bin/docker stop -t 10 {{.Name}} | ||
| 224 | Restart=always | ||
| 225 | RestartSec=5s | ||
| 226 | |||
| 227 | [Install] | ||
| 228 | WantedBy=multi-user.target | ||
| 229 | ` | ||
| 230 | |||
| 231 | var defaultAppCaddyTemplate = `{{.Domain}} { | ||
| 232 | reverse_proxy 127.0.0.1:{{.Port}} | ||
| 233 | } | ||
| 234 | ` | ||
| 235 | |||
| 236 | var defaultStaticCaddyTemplate = `{{.Domain}} { | ||
| 237 | root * /var/www/{{.Name}} | ||
| 238 | file_server | ||
| 239 | encode gzip | ||
| 240 | } | ||
| 241 | ` | ||
| 242 | |||
| 243 | // PostReceiveHook generates a post-receive hook for git-app repos | ||
| 244 | func PostReceiveHook(data map[string]string) (string, error) { | ||
| 245 | return renderTemplate("post-receive", postReceiveHookTemplate, data) | ||
| 246 | } | ||
| 247 | |||
| 248 | // PostReceiveHookStatic generates a post-receive hook for git-static repos | ||
| 249 | func PostReceiveHookStatic(data map[string]string) (string, error) { | ||
| 250 | return renderTemplate("post-receive-static", postReceiveHookStaticTemplate, data) | ||
| 251 | } | ||
| 252 | |||
| 253 | // CodeCaddy generates the base domain Caddy config for vanity imports + git HTTP | ||
| 254 | func CodeCaddy(data map[string]string) (string, error) { | ||
| 255 | return renderTemplate("code-caddy", codeCaddyTemplate, data) | ||
| 256 | } | ||
| 257 | |||
| 258 | var cgitrcTemplate = `virtual-root=/ | ||
| 259 | css=/cgit/cgit.css | ||
| 260 | logo=/cgit/cgit.png | ||
| 261 | header=/opt/ship/cgit-header.html | ||
| 262 | scan-path=/srv/git/ | ||
| 263 | export-ok=git-daemon-export-ok | ||
| 264 | enable-http-clone=0 | ||
| 265 | clone-url=https://{{.BaseDomain}}/$CGIT_REPO_URL | ||
| 266 | root-title={{.BaseDomain}} | ||
| 267 | root-desc= | ||
| 268 | remove-suffix=.git | ||
| 269 | ` | ||
| 270 | |||
| 271 | var cgitHeaderTemplate = `<style> | ||
| 272 | body, table, td, th, div#cgit { background: #1a1a2e; color: #ccc; } | ||
| 273 | a { color: #7aa2f7; } | ||
| 274 | a:hover { color: #9ecbff; } | ||
| 275 | table.list tr:hover td { background: #222244; } | ||
| 276 | table.list td, table.list th { border-bottom: 1px solid #333; } | ||
| 277 | th { background: #16213e; } | ||
| 278 | td.commitgraph .column1 { color: #7aa2f7; } | ||
| 279 | td.commitgraph .column2 { color: #9ece6a; } | ||
| 280 | td.logheader { background: #16213e; } | ||
| 281 | div#header { background: #16213e; border-bottom: 1px solid #333; } | ||
| 282 | div#header .sub { color: #888; } | ||
| 283 | table.tabs { border-bottom: 1px solid #333; } | ||
| 284 | table.tabs td a { color: #ccc; } | ||
| 285 | table.tabs td a.active { color: #fff; background: #1a1a2e; border: 1px solid #333; border-bottom: 1px solid #1a1a2e; } | ||
| 286 | div.footer { color: #555; } | ||
| 287 | div.footer a { color: #555; } | ||
| 288 | div.diffstat-header { background: #16213e; } | ||
| 289 | table.diffstat { border-bottom: 1px solid #333; } | ||
| 290 | table.diffstat td.graph span.graph-moreremoved { background: #f7768e; } | ||
| 291 | table.diffstat td.graph span.graph-moreadded { background: #9ece6a; } | ||
| 292 | table.diffstat td.graph span.graph-removed { background: #f7768e; } | ||
| 293 | table.diffstat td.graph span.graph-added { background: #9ece6a; } | ||
| 294 | table.diff { background: #131320; border: 1px solid #333; } | ||
| 295 | div.diff td { font-family: monospace; } | ||
| 296 | div.head { color: #ccc; background: #16213e; padding: 2px 4px; } | ||
| 297 | div.hunk { color: #7aa2f7; background: #1a1a3e; padding: 2px 4px; } | ||
| 298 | div.add { color: #9ece6a; background: #1a2e1a; padding: 2px 4px; } | ||
| 299 | div.del { color: #f7768e; background: #2e1a1a; padding: 2px 4px; } | ||
| 300 | table.diff td.add { color: #9ece6a; background: #1a2e1a; } | ||
| 301 | table.diff td.del { color: #f7768e; background: #2e1a1a; } | ||
| 302 | table.diff td.hunk { color: #7aa2f7; background: #1a1a3e; } | ||
| 303 | table.diff td { border: none; background: #1a1a2e; } | ||
| 304 | table.blob td.lines { color: #ccc; } | ||
| 305 | table.blob td.linenumbers { background: #16213e; } | ||
| 306 | table.blob td.linenumbers a { color: #555; } | ||
| 307 | table.blob td.linenumbers a:hover { color: #7aa2f7; } | ||
| 308 | table.ssdiff td.add { color: #9ece6a; background: #1a2e1a; } | ||
| 309 | table.ssdiff td.del { color: #f7768e; background: #2e1a1a; } | ||
| 310 | table.ssdiff td { border-right: 1px solid #333; } | ||
| 311 | table.ssdiff td.hunk { color: #7aa2f7; background: #1a1a3e; } | ||
| 312 | table.ssdiff td.head { background: #16213e; border-bottom: 1px solid #333; } | ||
| 313 | table.ssdiff td.foot { background: #16213e; border-top: 1px solid #333; } | ||
| 314 | table.ssdiff td.lineno { background: #16213e; color: #555; } | ||
| 315 | pre { color: #ccc; } | ||
| 316 | input, textarea, select { background: #222; color: #ccc; border: 1px solid #444; } | ||
| 317 | img#logo { display: none; } | ||
| 318 | </style> | ||
| 319 | ` | ||
| 320 | |||
| 321 | // CgitRC generates the /etc/cgitrc config file | ||
| 322 | func CgitRC(data map[string]string) (string, error) { | ||
| 323 | return renderTemplate("cgitrc", cgitrcTemplate, data) | ||
| 324 | } | ||
| 325 | |||
| 326 | // CgitHeader generates the cgit header HTML file (dark theme) | ||
| 327 | func CgitHeader() string { | ||
| 328 | return cgitHeaderTemplate | ||
| 329 | } | ||
| 330 | |||
| 331 | // DockerService generates a systemd unit for a Docker-based app | ||
| 332 | func DockerService(data map[string]string) (string, error) { | ||
| 333 | return renderTemplate("docker-service", dockerServiceTemplate, data) | ||
| 334 | } | ||
| 335 | |||
| 336 | // DefaultAppCaddy generates a default Caddyfile for a git-app | ||
| 337 | func DefaultAppCaddy(data map[string]string) (string, error) { | ||
| 338 | return renderTemplate("default-app-caddy", defaultAppCaddyTemplate, data) | ||
| 339 | } | ||
| 340 | |||
| 341 | // DefaultStaticCaddy generates a default Caddyfile for a git-static site | ||
| 342 | func DefaultStaticCaddy(data map[string]string) (string, error) { | ||
| 343 | return renderTemplate("default-static-caddy", defaultStaticCaddyTemplate, data) | ||
| 344 | } | ||
| 345 | |||
| 346 | func renderTemplate(name, tmplStr string, data map[string]string) (string, error) { | ||
| 347 | tmpl, err := template.New(name).Parse(tmplStr) | ||
| 348 | if err != nil { | ||
| 349 | return "", err | ||
| 350 | } | ||
| 351 | |||
| 352 | var buf bytes.Buffer | ||
| 353 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 354 | return "", err | ||
| 355 | } | ||
| 356 | |||
| 357 | return buf.String(), nil | ||
| 358 | } | ||
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 @@ | |||
| 1 | {{.Domain}} { | ||
| 2 | reverse_proxy 127.0.0.1:{{.Port}} | ||
| 3 | } | ||
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 @@ | |||
| 1 | [Unit] | ||
| 2 | Description={{.Name}} | ||
| 3 | After=network.target | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | Type=simple | ||
| 7 | User={{.User}} | ||
| 8 | WorkingDirectory={{.WorkDir}} | ||
| 9 | EnvironmentFile={{.EnvFile}} | ||
| 10 | ExecStart={{.BinaryPath}} --port={{.Port}} | ||
| 11 | Restart=always | ||
| 12 | RestartSec=5s | ||
| 13 | NoNewPrivileges=true | ||
| 14 | PrivateTmp=true | ||
| 15 | |||
| 16 | [Install] | ||
| 17 | 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 @@ | |||
| 1 | {{.Domain}} { | ||
| 2 | root * {{.RootDir}} | ||
| 3 | file_server | ||
| 4 | encode gzip | ||
| 5 | } | ||
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 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>About - Deploy Test</title> | ||
| 7 | <link rel="stylesheet" href="style.css"> | ||
| 8 | </head> | ||
| 9 | <body> | ||
| 10 | <header> | ||
| 11 | <nav> | ||
| 12 | <h1>Deploy Test</h1> | ||
| 13 | <ul> | ||
| 14 | <li><a href="index.html">Home</a></li> | ||
| 15 | <li><a href="about.html">About</a></li> | ||
| 16 | </ul> | ||
| 17 | </nav> | ||
| 18 | </header> | ||
| 19 | |||
| 20 | <main> | ||
| 21 | <section class="about"> | ||
| 22 | <h2>About This Site</h2> | ||
| 23 | <p>This is a test website created to demonstrate the deploy tool's static site deployment capabilities.</p> | ||
| 24 | |||
| 25 | <h3>Features</h3> | ||
| 26 | <ul> | ||
| 27 | <li>Simple HTML/CSS structure</li> | ||
| 28 | <li>Multiple pages for testing navigation</li> | ||
| 29 | <li>Responsive design</li> | ||
| 30 | <li>Clean and minimal styling</li> | ||
| 31 | </ul> | ||
| 32 | |||
| 33 | <h3>Deployment Command</h3> | ||
| 34 | <pre><code>./deploy deploy --host peerfile --static --dir ./test/example-website --domain example.com</code></pre> | ||
| 35 | </section> | ||
| 36 | </main> | ||
| 37 | |||
| 38 | <footer> | ||
| 39 | <p>© 2025 Deploy Test. Built for testing purposes.</p> | ||
| 40 | </footer> | ||
| 41 | </body> | ||
| 42 | </html> | ||
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 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>Example Website - Deploy Test</title> | ||
| 7 | <link rel="stylesheet" href="style.css"> | ||
| 8 | </head> | ||
| 9 | <body> | ||
| 10 | <header> | ||
| 11 | <nav> | ||
| 12 | <h1>Deploy Test</h1> | ||
| 13 | <ul> | ||
| 14 | <li><a href="index.html">Home</a></li> | ||
| 15 | <li><a href="about.html">About</a></li> | ||
| 16 | </ul> | ||
| 17 | </nav> | ||
| 18 | </header> | ||
| 19 | |||
| 20 | <main> | ||
| 21 | <section class="hero"> | ||
| 22 | <h2>Welcome to the Example Website</h2> | ||
| 23 | <p>This is a simple static website for testing the deploy tool.</p> | ||
| 24 | </section> | ||
| 25 | |||
| 26 | <section class="features"> | ||
| 27 | <div class="feature"> | ||
| 28 | <h3>Fast Deployment</h3> | ||
| 29 | <p>Deploy your static sites in seconds with a single command.</p> | ||
| 30 | </div> | ||
| 31 | <div class="feature"> | ||
| 32 | <h3>HTTPS Enabled</h3> | ||
| 33 | <p>Automatic SSL certificates with Caddy.</p> | ||
| 34 | </div> | ||
| 35 | <div class="feature"> | ||
| 36 | <h3>Simple Management</h3> | ||
| 37 | <p>Easy-to-use CLI for managing your deployments.</p> | ||
| 38 | </div> | ||
| 39 | </section> | ||
| 40 | </main> | ||
| 41 | |||
| 42 | <footer> | ||
| 43 | <p>© 2025 Deploy Test. Built for testing purposes.</p> | ||
| 44 | </footer> | ||
| 45 | </body> | ||
| 46 | </html> | ||
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 @@ | |||
| 1 | * { | ||
| 2 | margin: 0; | ||
| 3 | padding: 0; | ||
| 4 | box-sizing: border-box; | ||
| 5 | } | ||
| 6 | |||
| 7 | body { | ||
| 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | ||
| 9 | line-height: 1.6; | ||
| 10 | color: #333; | ||
| 11 | background: #f5f5f5; | ||
| 12 | } | ||
| 13 | |||
| 14 | header { | ||
| 15 | background: #2c3e50; | ||
| 16 | color: white; | ||
| 17 | padding: 1rem 0; | ||
| 18 | box-shadow: 0 2px 5px rgba(0,0,0,0.1); | ||
| 19 | } | ||
| 20 | |||
| 21 | nav { | ||
| 22 | max-width: 1200px; | ||
| 23 | margin: 0 auto; | ||
| 24 | padding: 0 2rem; | ||
| 25 | display: flex; | ||
| 26 | justify-content: space-between; | ||
| 27 | align-items: center; | ||
| 28 | } | ||
| 29 | |||
| 30 | nav h1 { | ||
| 31 | font-size: 1.5rem; | ||
| 32 | } | ||
| 33 | |||
| 34 | nav ul { | ||
| 35 | list-style: none; | ||
| 36 | display: flex; | ||
| 37 | gap: 2rem; | ||
| 38 | } | ||
| 39 | |||
| 40 | nav a { | ||
| 41 | color: white; | ||
| 42 | text-decoration: none; | ||
| 43 | transition: opacity 0.3s; | ||
| 44 | } | ||
| 45 | |||
| 46 | nav a:hover { | ||
| 47 | opacity: 0.8; | ||
| 48 | } | ||
| 49 | |||
| 50 | main { | ||
| 51 | max-width: 1200px; | ||
| 52 | margin: 2rem auto; | ||
| 53 | padding: 0 2rem; | ||
| 54 | } | ||
| 55 | |||
| 56 | .hero { | ||
| 57 | background: white; | ||
| 58 | padding: 3rem; | ||
| 59 | border-radius: 8px; | ||
| 60 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| 61 | text-align: center; | ||
| 62 | margin-bottom: 2rem; | ||
| 63 | } | ||
| 64 | |||
| 65 | .hero h2 { | ||
| 66 | font-size: 2.5rem; | ||
| 67 | margin-bottom: 1rem; | ||
| 68 | color: #2c3e50; | ||
| 69 | } | ||
| 70 | |||
| 71 | .hero p { | ||
| 72 | font-size: 1.2rem; | ||
| 73 | color: #666; | ||
| 74 | } | ||
| 75 | |||
| 76 | .features { | ||
| 77 | display: grid; | ||
| 78 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | ||
| 79 | gap: 2rem; | ||
| 80 | margin: 2rem 0; | ||
| 81 | } | ||
| 82 | |||
| 83 | .feature { | ||
| 84 | background: white; | ||
| 85 | padding: 2rem; | ||
| 86 | border-radius: 8px; | ||
| 87 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| 88 | } | ||
| 89 | |||
| 90 | .feature h3 { | ||
| 91 | color: #2c3e50; | ||
| 92 | margin-bottom: 0.5rem; | ||
| 93 | } | ||
| 94 | |||
| 95 | .feature p { | ||
| 96 | color: #666; | ||
| 97 | } | ||
| 98 | |||
| 99 | .about { | ||
| 100 | background: white; | ||
| 101 | padding: 3rem; | ||
| 102 | border-radius: 8px; | ||
| 103 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| 104 | } | ||
| 105 | |||
| 106 | .about h2 { | ||
| 107 | color: #2c3e50; | ||
| 108 | margin-bottom: 1rem; | ||
| 109 | } | ||
| 110 | |||
| 111 | .about h3 { | ||
| 112 | color: #34495e; | ||
| 113 | margin-top: 2rem; | ||
| 114 | margin-bottom: 0.5rem; | ||
| 115 | } | ||
| 116 | |||
| 117 | .about ul { | ||
| 118 | margin-left: 2rem; | ||
| 119 | margin-bottom: 1rem; | ||
| 120 | } | ||
| 121 | |||
| 122 | .about pre { | ||
| 123 | background: #f5f5f5; | ||
| 124 | padding: 1rem; | ||
| 125 | border-radius: 4px; | ||
| 126 | overflow-x: auto; | ||
| 127 | margin-top: 1rem; | ||
| 128 | } | ||
| 129 | |||
| 130 | .about code { | ||
| 131 | font-family: 'Courier New', monospace; | ||
| 132 | font-size: 0.9rem; | ||
| 133 | } | ||
| 134 | |||
| 135 | footer { | ||
| 136 | background: #2c3e50; | ||
| 137 | color: white; | ||
| 138 | text-align: center; | ||
| 139 | padding: 2rem; | ||
| 140 | margin-top: 4rem; | ||
| 141 | } | ||
| 142 | |||
| 143 | @media (max-width: 768px) { | ||
| 144 | nav { | ||
| 145 | flex-direction: column; | ||
| 146 | gap: 1rem; | ||
| 147 | } | ||
| 148 | |||
| 149 | .hero h2 { | ||
| 150 | font-size: 2rem; | ||
| 151 | } | ||
| 152 | |||
| 153 | .features { | ||
| 154 | grid-template-columns: 1fr; | ||
| 155 | } | ||
| 156 | } | ||
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 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>ship — deploy to your VPS</title> | ||
| 7 | <style> | ||
| 8 | :root { | ||
| 9 | --bg: #f5f2eb; | ||
| 10 | --fg: #2c2c2c; | ||
| 11 | --muted: #6b6b6b; | ||
| 12 | --accent: #1a1a1a; | ||
| 13 | --border: #d4d0c8; | ||
| 14 | } | ||
| 15 | |||
| 16 | * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| 17 | |||
| 18 | body { | ||
| 19 | font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace; | ||
| 20 | font-size: 16px; | ||
| 21 | line-height: 1.6; | ||
| 22 | background: var(--bg); | ||
| 23 | color: var(--fg); | ||
| 24 | } | ||
| 25 | |||
| 26 | main { | ||
| 27 | max-width: 640px; | ||
| 28 | margin: 0 auto; | ||
| 29 | padding: 4rem 1.5rem; | ||
| 30 | } | ||
| 31 | |||
| 32 | h1 { | ||
| 33 | font-size: 1.5rem; | ||
| 34 | font-weight: 600; | ||
| 35 | margin-bottom: 0.5rem; | ||
| 36 | } | ||
| 37 | |||
| 38 | .tagline { | ||
| 39 | color: var(--muted); | ||
| 40 | margin-bottom: 3rem; | ||
| 41 | } | ||
| 42 | |||
| 43 | section { | ||
| 44 | margin-bottom: 3rem; | ||
| 45 | } | ||
| 46 | |||
| 47 | h2 { | ||
| 48 | font-size: 0.875rem; | ||
| 49 | font-weight: 600; | ||
| 50 | text-transform: uppercase; | ||
| 51 | letter-spacing: 0.05em; | ||
| 52 | color: var(--muted); | ||
| 53 | margin-bottom: 1rem; | ||
| 54 | } | ||
| 55 | |||
| 56 | p { | ||
| 57 | margin-bottom: 1rem; | ||
| 58 | } | ||
| 59 | |||
| 60 | pre { | ||
| 61 | background: var(--accent); | ||
| 62 | color: var(--bg); | ||
| 63 | padding: 1rem 1.25rem; | ||
| 64 | overflow-x: auto; | ||
| 65 | font-size: 0.875rem; | ||
| 66 | line-height: 1.7; | ||
| 67 | margin-bottom: 1rem; | ||
| 68 | } | ||
| 69 | |||
| 70 | code { | ||
| 71 | font-family: inherit; | ||
| 72 | } | ||
| 73 | |||
| 74 | .comment { color: #888; } | ||
| 75 | .output { color: #a0a0a0; } | ||
| 76 | |||
| 77 | ul { | ||
| 78 | list-style: none; | ||
| 79 | margin-bottom: 1rem; | ||
| 80 | } | ||
| 81 | |||
| 82 | li { | ||
| 83 | padding-left: 1.25rem; | ||
| 84 | position: relative; | ||
| 85 | margin-bottom: 0.5rem; | ||
| 86 | } | ||
| 87 | |||
| 88 | li::before { | ||
| 89 | content: "—"; | ||
| 90 | position: absolute; | ||
| 91 | left: 0; | ||
| 92 | color: var(--muted); | ||
| 93 | } | ||
| 94 | |||
| 95 | a { | ||
| 96 | color: var(--fg); | ||
| 97 | text-decoration: underline; | ||
| 98 | text-underline-offset: 2px; | ||
| 99 | } | ||
| 100 | |||
| 101 | a:hover { | ||
| 102 | color: var(--muted); | ||
| 103 | } | ||
| 104 | |||
| 105 | .features { | ||
| 106 | display: grid; | ||
| 107 | gap: 1.5rem; | ||
| 108 | margin-bottom: 1rem; | ||
| 109 | } | ||
| 110 | |||
| 111 | .feature { | ||
| 112 | border: 1px solid var(--border); | ||
| 113 | padding: 1rem 1.25rem; | ||
| 114 | } | ||
| 115 | |||
| 116 | .feature strong { | ||
| 117 | display: block; | ||
| 118 | margin-bottom: 0.25rem; | ||
| 119 | } | ||
| 120 | |||
| 121 | .feature span { | ||
| 122 | color: var(--muted); | ||
| 123 | font-size: 0.875rem; | ||
| 124 | } | ||
| 125 | |||
| 126 | footer { | ||
| 127 | border-top: 1px solid var(--border); | ||
| 128 | padding-top: 2rem; | ||
| 129 | color: var(--muted); | ||
| 130 | font-size: 0.875rem; | ||
| 131 | } | ||
| 132 | </style> | ||
| 133 | </head> | ||
| 134 | <body> | ||
| 135 | <main> | ||
| 136 | <header> | ||
| 137 | <h1>ship</h1> | ||
| 138 | <p class="tagline">Deploy code to your VPS. Get a URL back.</p> | ||
| 139 | </header> | ||
| 140 | |||
| 141 | <section> | ||
| 142 | <h2>What it does</h2> | ||
| 143 | <p>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.</p> | ||
| 144 | <p>Built for AI agents. JSON output by default, predictable behavior, and an <a href="https://github.com/bdw/ship/tree/main/skill">agent skill</a> so your assistant can deploy code without hand-holding.</p> | ||
| 145 | </section> | ||
| 146 | |||
| 147 | <section> | ||
| 148 | <h2>Usage</h2> | ||
| 149 | <pre><span class="comment"># static site</span> | ||
| 150 | ship ./dist | ||
| 151 | <span class="output">→ https://ship-a1b2c3.example.com</span> | ||
| 152 | |||
| 153 | <span class="comment"># with a name</span> | ||
| 154 | ship ./dist --name docs | ||
| 155 | <span class="output">→ https://docs.example.com</span> | ||
| 156 | |||
| 157 | <span class="comment"># binary with health check</span> | ||
| 158 | ship ./myapp --name api --health /healthz | ||
| 159 | <span class="output">→ https://api.example.com</span> | ||
| 160 | |||
| 161 | <span class="comment"># temporary preview (auto-deletes)</span> | ||
| 162 | ship ./preview --ttl 1h | ||
| 163 | <span class="output">→ https://ship-x7y8z9.example.com (expires in 1h)</span> | ||
| 164 | |||
| 165 | <span class="comment"># custom domain</span> | ||
| 166 | ship ./site --domain myapp.com | ||
| 167 | <span class="output">→ https://myapp.com</span></pre> | ||
| 168 | </section> | ||
| 169 | |||
| 170 | <section> | ||
| 171 | <h2>Features</h2> | ||
| 172 | <div class="features"> | ||
| 173 | <div class="feature"> | ||
| 174 | <strong>Auto-detection</strong> | ||
| 175 | <span>Static sites, Docker apps, binaries — ship figures it out.</span> | ||
| 176 | </div> | ||
| 177 | <div class="feature"> | ||
| 178 | <strong>Automatic HTTPS</strong> | ||
| 179 | <span>Caddy handles certificates. You get HTTPS by default.</span> | ||
| 180 | </div> | ||
| 181 | <div class="feature"> | ||
| 182 | <strong>TTL support</strong> | ||
| 183 | <span>Temporary deploys that clean themselves up.</span> | ||
| 184 | </div> | ||
| 185 | <div class="feature"> | ||
| 186 | <strong>JSON output</strong> | ||
| 187 | <span>Built for scripts and automation. Parseable by default.</span> | ||
| 188 | </div> | ||
| 189 | </div> | ||
| 190 | </section> | ||
| 191 | |||
| 192 | <section> | ||
| 193 | <h2>One-time setup</h2> | ||
| 194 | <pre><span class="comment"># point ship at your VPS</span> | ||
| 195 | ship host init user@your-server --domain example.com</pre> | ||
| 196 | <p>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).</p> | ||
| 197 | </section> | ||
| 198 | |||
| 199 | <section> | ||
| 200 | <h2>Commands</h2> | ||
| 201 | <ul> | ||
| 202 | <li><code>ship <path></code> — deploy</li> | ||
| 203 | <li><code>ship list</code> — show all deployments</li> | ||
| 204 | <li><code>ship status <name></code> — check a deployment</li> | ||
| 205 | <li><code>ship logs <name></code> — view logs</li> | ||
| 206 | <li><code>ship remove <name></code> — take it down</li> | ||
| 207 | </ul> | ||
| 208 | </section> | ||
| 209 | |||
| 210 | <footer> | ||
| 211 | <p>Built for people who just want to put things on the internet.</p> | ||
| 212 | <p><a href="https://code.northwest.io/ship.git">source</a></p> | ||
| 213 | </footer> | ||
| 214 | </main> | ||
| 215 | </body> | ||
| 216 | </html> | ||
