diff options
| author | Clawd <ai@clawd.bot> | 2026-02-17 08:11:19 -0800 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-02-17 08:11:19 -0800 |
| commit | 6f02ec84a8299fc5577f147cc8741c8a4b162b64 (patch) | |
| tree | 020f3690e92732dcba723be0cfaef649f46de137 | |
| parent | 4b5a2656df13181b637c59c29ff31751e11cf22a (diff) | |
| parent | 05ea98df57599775c1d5bfea336012b075531670 (diff) | |
Merge agent-mode: v2 rewrite complete
- Removed all v1 code (-2800 lines)
- Simplified state to just default_host + base_domain
- Atomic port allocation via flock
- --container-port flag for Docker
- Custom domains shown in ship list
- Caddyfiles preserved on redeploy
- JSON output by default, --pretty for humans
40 files changed, 2919 insertions, 2828 deletions
| @@ -18,3 +18,6 @@ go.work | |||
| 18 | *.swp | 18 | *.swp |
| 19 | *.swo | 19 | *.swo |
| 20 | *~ | 20 | *~ |
| 21 | |||
| 22 | # Claude local settings | ||
| 23 | .claude/ | ||
| @@ -1,6 +1,11 @@ | |||
| 1 | .PHONY: build install deploy-website | ||
| 2 | |||
| 1 | build: | 3 | build: |
| 2 | go build -o ./bin/ship ./cmd/ship | 4 | go build -o ./bin/ship ./cmd/ship |
| 3 | 5 | ||
| 4 | install: | 6 | install: |
| 5 | cp ./bin/ship /usr/local/bin/ | 7 | cp ./bin/ship /usr/local/bin/ |
| 6 | 8 | ||
| 9 | deploy-website: | ||
| 10 | ship website/ ship.northwest.io | ||
| 11 | |||
diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..6ca83af --- /dev/null +++ b/PROGRESS.md | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | # Ship v2 Rebuild Progress | ||
| 2 | |||
| 3 | Tracking rebuilding ship for agent-first JSON interface. | ||
| 4 | |||
| 5 | ## Status: READY FOR TESTING | ||
| 6 | |||
| 7 | ## Completed | ||
| 8 | - [x] Design docs (SHIP_V2.md, SPEC.md) | ||
| 9 | - [x] JSON output types and helpers (`internal/output`) | ||
| 10 | - [x] Auto-detection logic (`internal/detect`) | ||
| 11 | - [x] Error codes and exit codes | ||
| 12 | - [x] New CLI structure (`ship [PATH]` as primary) | ||
| 13 | - [x] Deploy orchestration with placeholder implementations | ||
| 14 | |||
| 15 | ## Current Phase: Subcommand Implementations | ||
| 16 | - [x] Static site deploy (rsync + Caddyfile) | ||
| 17 | - [x] Docker deploy (build + systemd + Caddy) | ||
| 18 | - [x] Binary deploy (scp + systemd + Caddy) | ||
| 19 | - [x] Health check implementation | ||
| 20 | - [x] TTL support (server-side) | ||
| 21 | - [x] Port allocation (server-side) | ||
| 22 | |||
| 23 | ## Upcoming | ||
| 24 | - [ ] Testing with real deploys | ||
| 25 | - [ ] Remove v1 code after validation | ||
| 26 | |||
| 27 | ## Wiring | ||
| 28 | - v2 is now the default interface | ||
| 29 | - Set `SHIP_V1=1` to use legacy v1 (human-formatted output) | ||
| 30 | |||
| 31 | ## Completed Recently | ||
| 32 | - [x] `ship list` - enumerate all deploys from /etc/ship/ports and /var/www | ||
| 33 | - [x] `ship status NAME` - show deploy status, port, type, TTL | ||
| 34 | - [x] `ship logs NAME` - show journalctl logs (or Caddy logs for static) | ||
| 35 | - [x] `ship remove NAME` - full cleanup of all deploy artifacts | ||
| 36 | |||
| 37 | ## Completed Recently | ||
| 38 | - [x] TTL cleanup timer (server-side systemd timer) | ||
| 39 | - [x] `ship host init` with JSON output | ||
| 40 | - [x] Docker + Caddy installation | ||
| 41 | - [x] Cleanup script for expired TTL deploys | ||
| 42 | |||
| 43 | ## Commits | ||
| 44 | - `5b88935` feat(v2): add output and detect packages | ||
| 45 | - `8094639` feat(v2): add CLI structure and deploy orchestration | ||
| 46 | - `1463935` feat(v2): implement deploy flows | ||
| 47 | - `a7436df` feat(v2): implement ship host init with JSON output | ||
| 48 | - `d6740a0` feat(v2): implement list, status, logs, remove commands | ||
| 49 | - `4c20fb3` feat(v2): wire up v2 as default interface | ||
| 50 | |||
| 51 | --- | ||
| 52 | |||
| 53 | ## Notes | ||
| 54 | - Branch: `agent-mode` | ||
| 55 | - Keep v1 code for reference until v2 is working | ||
| 56 | - Test with real deploys before merging to main | ||
diff --git a/SHIP_V2.md b/SHIP_V2.md new file mode 100644 index 0000000..7b607cb --- /dev/null +++ b/SHIP_V2.md | |||
| @@ -0,0 +1,240 @@ | |||
| 1 | # Ship v2 | ||
| 2 | |||
| 3 | **Ship is a deployment tool built for AI agents.** | ||
| 4 | |||
| 5 | Agents write code. Ship puts it on the internet. That's it. | ||
| 6 | |||
| 7 | ## The Problem | ||
| 8 | |||
| 9 | AI agents can write code, but deploying it is a mess: | ||
| 10 | - Vercel/Railway/Fly require accounts, API tokens, platform-specific config | ||
| 11 | - Docker/K8s are overkill for "make this code accessible via URL" | ||
| 12 | - Most tools assume a human is reading the output | ||
| 13 | |||
| 14 | Agents need: code → URL. Nothing else. | ||
| 15 | |||
| 16 | ## Ship's Approach | ||
| 17 | |||
| 18 | ```bash | ||
| 19 | ship ./myproject | ||
| 20 | # {"status":"ok","url":"https://abc123.example.com","took_ms":4200} | ||
| 21 | ``` | ||
| 22 | |||
| 23 | **That's the entire interface.** Point ship at code, get a URL back as JSON. | ||
| 24 | |||
| 25 | ### Core Principles | ||
| 26 | |||
| 27 | 1. **JSON in, JSON out** — No human-formatted output. No spinners. No emoji. Agents parse JSON. | ||
| 28 | |||
| 29 | 2. **One command** — No workflows. No "init then configure then deploy." One command does everything. | ||
| 30 | |||
| 31 | 3. **Verify or fail** — Every deploy is health-checked. If the app isn't responding, ship returns an error. No silent failures. | ||
| 32 | |||
| 33 | 4. **Self-cleaning** — Deploys can auto-expire. Agents create lots of previews; they shouldn't pile up forever. | ||
| 34 | |||
| 35 | 5. **Zero config** — Point at a directory. Ship figures out if it's static, Docker, or a binary. No config files required. | ||
| 36 | |||
| 37 | 6. **SSH-only** — No accounts. No API tokens. No vendor lock-in. Just SSH access to a VPS. | ||
| 38 | |||
| 39 | ## Interface | ||
| 40 | |||
| 41 | ### Deploy | ||
| 42 | |||
| 43 | ```bash | ||
| 44 | ship ./myproject | ||
| 45 | ship ./myproject --name myapp | ||
| 46 | ship ./myproject --name preview --ttl 24h | ||
| 47 | ship ./site --static | ||
| 48 | ship ./api --health /healthz | ||
| 49 | ``` | ||
| 50 | |||
| 51 | Output: | ||
| 52 | ```json | ||
| 53 | { | ||
| 54 | "status": "ok", | ||
| 55 | "name": "myapp", | ||
| 56 | "url": "https://myapp.example.com", | ||
| 57 | "type": "docker", | ||
| 58 | "took_ms": 4200, | ||
| 59 | "health": {"status": 200, "latency_ms": 45} | ||
| 60 | } | ||
| 61 | ``` | ||
| 62 | |||
| 63 | ### Error | ||
| 64 | |||
| 65 | ```json | ||
| 66 | { | ||
| 67 | "status": "error", | ||
| 68 | "code": "HEALTH_CHECK_FAILED", | ||
| 69 | "message": "GET /healthz returned 503 after 30s", | ||
| 70 | "name": "myapp", | ||
| 71 | "url": "https://myapp.example.com" | ||
| 72 | } | ||
| 73 | ``` | ||
| 74 | |||
| 75 | ### List | ||
| 76 | |||
| 77 | ```bash | ||
| 78 | ship list | ||
| 79 | ``` | ||
| 80 | |||
| 81 | ```json | ||
| 82 | { | ||
| 83 | "status": "ok", | ||
| 84 | "deploys": [ | ||
| 85 | {"name": "api", "url": "https://api.example.com", "type": "docker", "running": true}, | ||
| 86 | {"name": "preview-x7k", "url": "https://preview-x7k.example.com", "type": "static", "expires": "2024-02-16T18:00:00Z"} | ||
| 87 | ] | ||
| 88 | } | ||
| 89 | ``` | ||
| 90 | |||
| 91 | ### Status / Logs / Remove | ||
| 92 | |||
| 93 | ```bash | ||
| 94 | ship status myapp | ||
| 95 | ship logs myapp | ||
| 96 | ship logs myapp --lines 100 | ||
| 97 | ship remove myapp | ||
| 98 | ``` | ||
| 99 | |||
| 100 | All return JSON with `{"status": "ok", ...}` or `{"status": "error", "code": "...", ...}`. | ||
| 101 | |||
| 102 | ## Error Codes | ||
| 103 | |||
| 104 | Machine-readable. No guessing. | ||
| 105 | |||
| 106 | | Code | Meaning | | ||
| 107 | |------|---------| | ||
| 108 | | `SSH_FAILED` | Can't connect to VPS | | ||
| 109 | | `UPLOAD_FAILED` | File transfer failed | | ||
| 110 | | `BUILD_FAILED` | Docker build or compile failed | | ||
| 111 | | `DEPLOY_FAILED` | systemd/Caddy setup failed | | ||
| 112 | | `HEALTH_CHECK_FAILED` | App not responding | | ||
| 113 | | `NOT_FOUND` | App doesn't exist | | ||
| 114 | | `CONFLICT` | Name already taken | | ||
| 115 | |||
| 116 | ## Auto-Detection | ||
| 117 | |||
| 118 | Ship looks at the directory and figures out what to do: | ||
| 119 | |||
| 120 | | Directory contains | Deploy type | | ||
| 121 | |-------------------|-------------| | ||
| 122 | | `Dockerfile` | Docker build → systemd service | | ||
| 123 | | `index.html` | Static site → Caddy file_server | | ||
| 124 | | Single executable | Binary → systemd service | | ||
| 125 | | `go.mod` | Go build → systemd service | | ||
| 126 | | `package.json` + no Dockerfile | Error: "Add a Dockerfile" | | ||
| 127 | |||
| 128 | No config files. No `ship.json`. No `ship init`. | ||
| 129 | |||
| 130 | ## TTL (Time-To-Live) | ||
| 131 | |||
| 132 | Agents create previews. Previews should auto-delete. | ||
| 133 | |||
| 134 | ```bash | ||
| 135 | ship ./site --name pr-123 --ttl 1h | ||
| 136 | ship ./site --name pr-123 --ttl 7d | ||
| 137 | ``` | ||
| 138 | |||
| 139 | After TTL expires, the deploy is removed automatically. The `expires` field in JSON tells you when. | ||
| 140 | |||
| 141 | ## Health Checks | ||
| 142 | |||
| 143 | Every deploy is verified. Ship waits for the app to respond before returning success. | ||
| 144 | |||
| 145 | - Static sites: `GET /` returns 2xx | ||
| 146 | - Apps: `GET /` by default, or specify `--health /healthz` | ||
| 147 | - Timeout: 30s | ||
| 148 | - If health check fails: `{"status": "error", "code": "HEALTH_CHECK_FAILED", ...}` | ||
| 149 | |||
| 150 | ## Name Generation | ||
| 151 | |||
| 152 | No name? Ship generates one. | ||
| 153 | |||
| 154 | ```bash | ||
| 155 | ship ./site | ||
| 156 | # {"name": "ship-a1b2c3", "url": "https://ship-a1b2c3.example.com", ...} | ||
| 157 | ``` | ||
| 158 | |||
| 159 | Provide a name to get a stable URL: | ||
| 160 | |||
| 161 | ```bash | ||
| 162 | ship ./site --name docs | ||
| 163 | # {"name": "docs", "url": "https://docs.example.com", ...} | ||
| 164 | ``` | ||
| 165 | |||
| 166 | ## Host Setup | ||
| 167 | |||
| 168 | One-time setup for a VPS: | ||
| 169 | |||
| 170 | ```bash | ||
| 171 | ship host init user@my-vps.com --domain example.com | ||
| 172 | ``` | ||
| 173 | |||
| 174 | ```json | ||
| 175 | { | ||
| 176 | "status": "ok", | ||
| 177 | "host": "my-vps.com", | ||
| 178 | "domain": "example.com", | ||
| 179 | "installed": ["caddy", "docker", "systemd"] | ||
| 180 | } | ||
| 181 | ``` | ||
| 182 | |||
| 183 | After this, the host is ready. Ship remembers it. | ||
| 184 | |||
| 185 | ## Human Output | ||
| 186 | |||
| 187 | Humans are an afterthought, but they can use ship too: | ||
| 188 | |||
| 189 | ```bash | ||
| 190 | ship ./site --pretty | ||
| 191 | ``` | ||
| 192 | |||
| 193 | ``` | ||
| 194 | ✓ Deployed to https://ship-a1b2c3.example.com (4.2s) | ||
| 195 | ``` | ||
| 196 | |||
| 197 | Or set globally: | ||
| 198 | ```bash | ||
| 199 | export SHIP_PRETTY=1 | ||
| 200 | ``` | ||
| 201 | |||
| 202 | ## Implementation Phases | ||
| 203 | |||
| 204 | ### Phase 1: JSON Everything | ||
| 205 | - [ ] JSON output on all commands | ||
| 206 | - [ ] Structured error codes | ||
| 207 | - [ ] Exit codes match error states | ||
| 208 | |||
| 209 | ### Phase 2: Smart Deploys | ||
| 210 | - [ ] Auto-detect project type | ||
| 211 | - [ ] Health checks on every deploy | ||
| 212 | - [ ] `--ttl` with server-side cleanup | ||
| 213 | |||
| 214 | ### Phase 3: Zero Friction | ||
| 215 | - [ ] `ship ./dir` with no flags (auto name, auto detect) | ||
| 216 | - [ ] `ship host init` fully automated | ||
| 217 | - [ ] One binary, zero dependencies on client | ||
| 218 | |||
| 219 | ## Non-Goals | ||
| 220 | |||
| 221 | - **Pretty output** — That's what `--pretty` is for | ||
| 222 | - **Interactive prompts** — Never. Agents can't answer prompts. | ||
| 223 | - **Config files** — Zero config. Detect everything. | ||
| 224 | - **Plugin system** — Keep it simple. | ||
| 225 | - **Multi-cloud orchestration** — One VPS at a time. | ||
| 226 | |||
| 227 | ## Success Criteria | ||
| 228 | |||
| 229 | Ship is done when an agent can: | ||
| 230 | |||
| 231 | 1. Build code | ||
| 232 | 2. Run `ship ./code` | ||
| 233 | 3. Parse the JSON response | ||
| 234 | 4. Use the URL | ||
| 235 | |||
| 236 | No docs. No setup. No tokens. No accounts. Just `ship ./code`. | ||
| 237 | |||
| 238 | --- | ||
| 239 | |||
| 240 | *Built for agents. Tolerated by humans.* | ||
| @@ -0,0 +1,499 @@ | |||
| 1 | # Ship v2 Technical Specification | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | Ship deploys code to a VPS. Input: path to code. Output: JSON with URL. | ||
| 6 | |||
| 7 | ```bash | ||
| 8 | ship ./myproject | ||
| 9 | # {"status":"ok","name":"ship-a1b2c3","url":"https://ship-a1b2c3.example.com","type":"docker","took_ms":8200} | ||
| 10 | ``` | ||
| 11 | |||
| 12 | ## CLI Interface | ||
| 13 | |||
| 14 | ### Primary Command | ||
| 15 | |||
| 16 | ``` | ||
| 17 | ship [PATH] [FLAGS] | ||
| 18 | ``` | ||
| 19 | |||
| 20 | **PATH**: File or directory to deploy. Defaults to `.` (current directory). | ||
| 21 | |||
| 22 | **FLAGS**: | ||
| 23 | | Flag | Type | Default | Description | | ||
| 24 | |------|------|---------|-------------| | ||
| 25 | | `--name` | string | auto-generated | Deploy name (becomes subdomain) | | ||
| 26 | | `--host` | string | default host | VPS host (SSH config name or user@host) | | ||
| 27 | | `--health` | string | `/` for static, none for apps | Health check endpoint | | ||
| 28 | | `--ttl` | duration | none | Auto-delete after duration (e.g., `1h`, `7d`) | | ||
| 29 | | `--env` | string[] | none | Environment variables (`KEY=VALUE`) | | ||
| 30 | | `--env-file` | string | none | Path to .env file | | ||
| 31 | | `--pretty` | bool | false | Human-readable output | | ||
| 32 | |||
| 33 | ### Other Commands | ||
| 34 | |||
| 35 | ``` | ||
| 36 | ship list [--host HOST] | ||
| 37 | ship status NAME [--host HOST] | ||
| 38 | ship logs NAME [--lines N] [--host HOST] | ||
| 39 | ship remove NAME [--host HOST] | ||
| 40 | ship host init USER@HOST --domain DOMAIN | ||
| 41 | ship host status [--host HOST] | ||
| 42 | ``` | ||
| 43 | |||
| 44 | All commands output JSON unless `--pretty` is set. | ||
| 45 | |||
| 46 | ## Output Schema | ||
| 47 | |||
| 48 | ### Success Response | ||
| 49 | |||
| 50 | ```typescript | ||
| 51 | { | ||
| 52 | status: "ok", | ||
| 53 | name: string, | ||
| 54 | url: string, | ||
| 55 | type: "static" | "docker" | "binary", | ||
| 56 | took_ms: number, | ||
| 57 | health?: { | ||
| 58 | endpoint: string, | ||
| 59 | status: number, | ||
| 60 | latency_ms: number | ||
| 61 | }, | ||
| 62 | expires?: string // ISO 8601, only if --ttl set | ||
| 63 | } | ||
| 64 | ``` | ||
| 65 | |||
| 66 | ### Error Response | ||
| 67 | |||
| 68 | ```typescript | ||
| 69 | { | ||
| 70 | status: "error", | ||
| 71 | code: string, | ||
| 72 | message: string, | ||
| 73 | name?: string, | ||
| 74 | url?: string | ||
| 75 | } | ||
| 76 | ``` | ||
| 77 | |||
| 78 | ### Exit Codes | ||
| 79 | |||
| 80 | | Code | Meaning | | ||
| 81 | |------|---------| | ||
| 82 | | 0 | Success | | ||
| 83 | | 1 | Deploy failed | | ||
| 84 | | 2 | Invalid arguments | | ||
| 85 | | 3 | SSH connection failed | | ||
| 86 | | 4 | Health check failed | | ||
| 87 | |||
| 88 | ## Error Codes | ||
| 89 | |||
| 90 | | Code | Description | | ||
| 91 | |------|-------------| | ||
| 92 | | `INVALID_PATH` | Path doesn't exist or isn't readable | | ||
| 93 | | `UNKNOWN_PROJECT_TYPE` | Can't detect how to deploy | | ||
| 94 | | `SSH_CONNECT_FAILED` | Can't establish SSH connection | | ||
| 95 | | `SSH_AUTH_FAILED` | SSH key rejected | | ||
| 96 | | `UPLOAD_FAILED` | SCP/rsync failed | | ||
| 97 | | `BUILD_FAILED` | Docker build or compilation failed | | ||
| 98 | | `SERVICE_FAILED` | systemd unit failed to start | | ||
| 99 | | `CADDY_FAILED` | Caddy reload failed | | ||
| 100 | | `HEALTH_CHECK_FAILED` | App didn't respond in time | | ||
| 101 | | `HEALTH_CHECK_TIMEOUT` | Health check timed out | | ||
| 102 | | `NOT_FOUND` | Named deploy doesn't exist | | ||
| 103 | | `CONFLICT` | Name already in use (if --no-update) | | ||
| 104 | | `HOST_NOT_CONFIGURED` | No default host, --host required | | ||
| 105 | | `INVALID_TTL` | Can't parse TTL duration | | ||
| 106 | |||
| 107 | ## Auto-Detection Logic | ||
| 108 | |||
| 109 | When given a path, ship determines deploy type: | ||
| 110 | |||
| 111 | ``` | ||
| 112 | is_file(path)? | ||
| 113 | → is_executable(path)? | ||
| 114 | → BINARY | ||
| 115 | → ERROR: "Not an executable file" | ||
| 116 | |||
| 117 | is_directory(path)? | ||
| 118 | → has_file("Dockerfile")? | ||
| 119 | → DOCKER | ||
| 120 | → has_file("index.html") OR has_file("index.htm")? | ||
| 121 | → STATIC | ||
| 122 | → has_file("go.mod") AND NOT has_file("Dockerfile")? | ||
| 123 | → ERROR: "Go project without Dockerfile. Add a Dockerfile or build a binary." | ||
| 124 | → has_file("package.json") AND NOT has_file("Dockerfile")? | ||
| 125 | → ERROR: "Node project without Dockerfile. Add a Dockerfile." | ||
| 126 | → is_empty(path)? | ||
| 127 | → ERROR: "Directory is empty" | ||
| 128 | → ERROR: "Can't detect project type. Add a Dockerfile or index.html." | ||
| 129 | ``` | ||
| 130 | |||
| 131 | ## Name Generation | ||
| 132 | |||
| 133 | If `--name` not provided: | ||
| 134 | |||
| 135 | ``` | ||
| 136 | name = "ship-" + random_alphanumeric(6) | ||
| 137 | ``` | ||
| 138 | |||
| 139 | Names must match: `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$` | ||
| 140 | |||
| 141 | Invalid names return `INVALID_NAME` error. | ||
| 142 | |||
| 143 | ## Port Allocation | ||
| 144 | |||
| 145 | Ports are allocated server-side. | ||
| 146 | |||
| 147 | ### Server Files | ||
| 148 | |||
| 149 | ``` | ||
| 150 | /etc/ship/ports/<name> # Contains allocated port number | ||
| 151 | ``` | ||
| 152 | |||
| 153 | ### Allocation Algorithm | ||
| 154 | |||
| 155 | ```python | ||
| 156 | def allocate_port(name): | ||
| 157 | port_file = f"/etc/ship/ports/{name}" | ||
| 158 | |||
| 159 | if exists(port_file): | ||
| 160 | return int(read(port_file)) | ||
| 161 | |||
| 162 | used_ports = [int(read(f)) for f in glob("/etc/ship/ports/*")] | ||
| 163 | next_port = 9000 | ||
| 164 | while next_port in used_ports: | ||
| 165 | next_port += 1 | ||
| 166 | |||
| 167 | write(port_file, str(next_port)) | ||
| 168 | return next_port | ||
| 169 | ``` | ||
| 170 | |||
| 171 | ### Port Range | ||
| 172 | |||
| 173 | - Start: 9000 | ||
| 174 | - Max: 9999 | ||
| 175 | - Error if exhausted: `PORT_EXHAUSTED` | ||
| 176 | |||
| 177 | ## Server File Layout | ||
| 178 | |||
| 179 | ``` | ||
| 180 | /etc/ship/ | ||
| 181 | ports/<name> # Port allocation (contains port number) | ||
| 182 | env/<name>.env # Environment variables | ||
| 183 | ttl/<name> # TTL expiry timestamp (Unix epoch) | ||
| 184 | |||
| 185 | /var/www/<name>/ # Static site files | ||
| 186 | |||
| 187 | /var/lib/<name>/ | ||
| 188 | src/ # Docker build context (checked out source) | ||
| 189 | data/ # Persistent data volume | ||
| 190 | |||
| 191 | /etc/systemd/system/<name>.service # systemd unit | ||
| 192 | /etc/caddy/sites-enabled/<name>.caddy # Caddy config | ||
| 193 | ``` | ||
| 194 | |||
| 195 | ## Deploy Flows | ||
| 196 | |||
| 197 | ### Static Site | ||
| 198 | |||
| 199 | 1. Validate path is directory with index.html | ||
| 200 | 2. Generate name if needed | ||
| 201 | 3. SSH: Create `/var/www/<name>/` | ||
| 202 | 4. rsync: Upload directory contents to `/var/www/<name>/` | ||
| 203 | 5. SSH: Set ownership to `www-data` | ||
| 204 | 6. Generate Caddyfile | ||
| 205 | 7. SSH: Write to `/etc/caddy/sites-enabled/<name>.caddy` | ||
| 206 | 8. SSH: Reload Caddy | ||
| 207 | 9. Health check: `GET https://<name>.<domain>/` | ||
| 208 | 10. If TTL: Write expiry to `/etc/ship/ttl/<name>` | ||
| 209 | 11. Return JSON | ||
| 210 | |||
| 211 | ### Docker | ||
| 212 | |||
| 213 | 1. Validate path is directory with Dockerfile | ||
| 214 | 2. Generate name if needed | ||
| 215 | 3. Allocate port (server-side) | ||
| 216 | 4. rsync: Upload directory to `/var/lib/<name>/src/` | ||
| 217 | 5. SSH: `docker build -t <name> /var/lib/<name>/src/` | ||
| 218 | 6. Generate systemd unit (runs `docker run`) | ||
| 219 | 7. SSH: Write to `/etc/systemd/system/<name>.service` | ||
| 220 | 8. SSH: Write env file to `/etc/ship/env/<name>.env` | ||
| 221 | 9. SSH: `systemctl daemon-reload && systemctl restart <name>` | ||
| 222 | 10. Generate Caddyfile (reverse proxy to port) | ||
| 223 | 11. SSH: Write to `/etc/caddy/sites-enabled/<name>.caddy` | ||
| 224 | 12. SSH: Reload Caddy | ||
| 225 | 13. Health check: `GET https://<name>.<domain><health_endpoint>` | ||
| 226 | 14. If TTL: Write expiry to `/etc/ship/ttl/<name>` | ||
| 227 | 15. Return JSON | ||
| 228 | |||
| 229 | ### Binary | ||
| 230 | |||
| 231 | 1. Validate path is executable file | ||
| 232 | 2. Generate name if needed | ||
| 233 | 3. Allocate port (server-side) | ||
| 234 | 4. SCP: Upload binary to `/usr/local/bin/<name>` | ||
| 235 | 5. SSH: `chmod +x /usr/local/bin/<name>` | ||
| 236 | 6. Generate systemd unit | ||
| 237 | 7. SSH: Write to `/etc/systemd/system/<name>.service` | ||
| 238 | 8. SSH: Write env file to `/etc/ship/env/<name>.env` | ||
| 239 | 9. SSH: `systemctl daemon-reload && systemctl restart <name>` | ||
| 240 | 10. Generate Caddyfile (reverse proxy to port) | ||
| 241 | 11. SSH: Write to `/etc/caddy/sites-enabled/<name>.caddy` | ||
| 242 | 12. SSH: Reload Caddy | ||
| 243 | 13. Health check: `GET https://<name>.<domain><health_endpoint>` | ||
| 244 | 14. If TTL: Write expiry to `/etc/ship/ttl/<name>` | ||
| 245 | 15. Return JSON | ||
| 246 | |||
| 247 | ## Health Checks | ||
| 248 | |||
| 249 | ### Behavior | ||
| 250 | |||
| 251 | After deploy, verify the app is responding: | ||
| 252 | |||
| 253 | 1. Wait 2 seconds (let app start) | ||
| 254 | 2. `GET https://<name>.<domain><health_endpoint>` | ||
| 255 | 3. If 2xx or 3xx: success | ||
| 256 | 4. If error or timeout: retry after 2s | ||
| 257 | 5. Max retries: 15 (total 30s) | ||
| 258 | 6. If all retries fail: return `HEALTH_CHECK_FAILED` | ||
| 259 | |||
| 260 | ### Health Endpoint | ||
| 261 | |||
| 262 | | Deploy Type | Default | Override | | ||
| 263 | |-------------|---------|----------| | ||
| 264 | | static | `/` | `--health` | | ||
| 265 | | docker | none (skip) | `--health` | | ||
| 266 | | binary | none (skip) | `--health` | | ||
| 267 | |||
| 268 | When no health endpoint: skip health check, return success after service starts. | ||
| 269 | |||
| 270 | ## TTL (Auto-Expiry) | ||
| 271 | |||
| 272 | ### Setting TTL | ||
| 273 | |||
| 274 | ```bash | ||
| 275 | ship ./site --name preview --ttl 24h | ||
| 276 | ``` | ||
| 277 | |||
| 278 | Supported formats: `30m`, `1h`, `24h`, `7d` | ||
| 279 | |||
| 280 | ### Server-Side Cleanup | ||
| 281 | |||
| 282 | A systemd timer runs hourly: | ||
| 283 | |||
| 284 | ```ini | ||
| 285 | # /etc/systemd/system/ship-cleanup.timer | ||
| 286 | [Timer] | ||
| 287 | OnCalendar=hourly | ||
| 288 | Persistent=true | ||
| 289 | |||
| 290 | # /etc/systemd/system/ship-cleanup.service | ||
| 291 | [Service] | ||
| 292 | Type=oneshot | ||
| 293 | ExecStart=/usr/local/bin/ship-cleanup | ||
| 294 | ``` | ||
| 295 | |||
| 296 | Cleanup script: | ||
| 297 | |||
| 298 | ```bash | ||
| 299 | #!/bin/bash | ||
| 300 | now=$(date +%s) | ||
| 301 | for f in /etc/ship/ttl/*; do | ||
| 302 | name=$(basename "$f") | ||
| 303 | expires=$(cat "$f") | ||
| 304 | if [ "$now" -gt "$expires" ]; then | ||
| 305 | ship remove "$name" | ||
| 306 | fi | ||
| 307 | done | ||
| 308 | ``` | ||
| 309 | |||
| 310 | ### TTL File Format | ||
| 311 | |||
| 312 | ``` | ||
| 313 | /etc/ship/ttl/<name> | ||
| 314 | ``` | ||
| 315 | |||
| 316 | Contents: Unix timestamp (seconds since epoch) | ||
| 317 | |||
| 318 | ## Templates | ||
| 319 | |||
| 320 | ### systemd Unit (Docker) | ||
| 321 | |||
| 322 | ```ini | ||
| 323 | [Unit] | ||
| 324 | Description=ship: {{.Name}} | ||
| 325 | After=docker.service | ||
| 326 | Requires=docker.service | ||
| 327 | |||
| 328 | [Service] | ||
| 329 | Restart=always | ||
| 330 | RestartSec=5 | ||
| 331 | EnvironmentFile=/etc/ship/env/{{.Name}}.env | ||
| 332 | ExecStartPre=-/usr/bin/docker stop {{.Name}} | ||
| 333 | ExecStartPre=-/usr/bin/docker rm {{.Name}} | ||
| 334 | ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ | ||
| 335 | --env-file /etc/ship/env/{{.Name}}.env \ | ||
| 336 | -p 127.0.0.1:{{.Port}}:{{.Port}} \ | ||
| 337 | -v /var/lib/{{.Name}}/data:/data \ | ||
| 338 | {{.Name}} | ||
| 339 | ExecStop=/usr/bin/docker stop {{.Name}} | ||
| 340 | |||
| 341 | [Install] | ||
| 342 | WantedBy=multi-user.target | ||
| 343 | ``` | ||
| 344 | |||
| 345 | ### systemd Unit (Binary) | ||
| 346 | |||
| 347 | ```ini | ||
| 348 | [Unit] | ||
| 349 | Description=ship: {{.Name}} | ||
| 350 | After=network.target | ||
| 351 | |||
| 352 | [Service] | ||
| 353 | Type=simple | ||
| 354 | Restart=always | ||
| 355 | RestartSec=5 | ||
| 356 | User={{.Name}} | ||
| 357 | EnvironmentFile=/etc/ship/env/{{.Name}}.env | ||
| 358 | ExecStart=/usr/local/bin/{{.Name}} | ||
| 359 | WorkingDirectory=/var/lib/{{.Name}} | ||
| 360 | |||
| 361 | [Install] | ||
| 362 | WantedBy=multi-user.target | ||
| 363 | ``` | ||
| 364 | |||
| 365 | ### Caddyfile (Reverse Proxy) | ||
| 366 | |||
| 367 | ``` | ||
| 368 | {{.Domain}} { | ||
| 369 | reverse_proxy localhost:{{.Port}} | ||
| 370 | } | ||
| 371 | ``` | ||
| 372 | |||
| 373 | ### Caddyfile (Static) | ||
| 374 | |||
| 375 | ``` | ||
| 376 | {{.Domain}} { | ||
| 377 | root * /var/www/{{.Name}} | ||
| 378 | file_server | ||
| 379 | encode gzip | ||
| 380 | } | ||
| 381 | ``` | ||
| 382 | |||
| 383 | ## Environment Variables | ||
| 384 | |||
| 385 | Always set by ship: | ||
| 386 | |||
| 387 | | Variable | Value | | ||
| 388 | |----------|-------| | ||
| 389 | | `PORT` | Allocated port number | | ||
| 390 | | `SHIP_NAME` | Deploy name | | ||
| 391 | | `SHIP_URL` | Full URL | | ||
| 392 | |||
| 393 | User variables from `--env` or `--env-file` are merged. | ||
| 394 | |||
| 395 | ## Host Initialization | ||
| 396 | |||
| 397 | ```bash | ||
| 398 | ship host init user@vps.example.com --domain example.com | ||
| 399 | ``` | ||
| 400 | |||
| 401 | ### Steps | ||
| 402 | |||
| 403 | 1. SSH: Check connectivity | ||
| 404 | 2. SSH: Install packages (`apt install -y caddy docker.io`) | ||
| 405 | 3. SSH: Enable services (`systemctl enable --now caddy docker`) | ||
| 406 | 4. SSH: Create directories (`/etc/ship/ports`, `/etc/ship/env`, `/etc/ship/ttl`) | ||
| 407 | 5. SSH: Install cleanup timer | ||
| 408 | 6. SSH: Configure Caddy base | ||
| 409 | 7. Update local state with host info | ||
| 410 | |||
| 411 | ### Output | ||
| 412 | |||
| 413 | ```json | ||
| 414 | { | ||
| 415 | "status": "ok", | ||
| 416 | "host": "vps.example.com", | ||
| 417 | "domain": "example.com", | ||
| 418 | "installed": ["caddy", "docker"] | ||
| 419 | } | ||
| 420 | ``` | ||
| 421 | |||
| 422 | ## Local State | ||
| 423 | |||
| 424 | Stored at `~/.config/ship/state.json`: | ||
| 425 | |||
| 426 | ```json | ||
| 427 | { | ||
| 428 | "default_host": "vps", | ||
| 429 | "hosts": { | ||
| 430 | "vps": { | ||
| 431 | "ssh": "user@vps.example.com", | ||
| 432 | "domain": "example.com" | ||
| 433 | } | ||
| 434 | } | ||
| 435 | } | ||
| 436 | ``` | ||
| 437 | |||
| 438 | Local state is minimal — server is source of truth for ports, deploys, TTLs. | ||
| 439 | |||
| 440 | ## Pretty Output | ||
| 441 | |||
| 442 | When `--pretty` is set (or `SHIP_PRETTY=1`): | ||
| 443 | |||
| 444 | ### Deploy Success | ||
| 445 | |||
| 446 | ``` | ||
| 447 | ✓ Deployed to https://myapp.example.com (4.2s) | ||
| 448 | ``` | ||
| 449 | |||
| 450 | ### Deploy Failure | ||
| 451 | |||
| 452 | ``` | ||
| 453 | ✗ Deploy failed: health check timed out after 30s | ||
| 454 | ``` | ||
| 455 | |||
| 456 | ### List | ||
| 457 | |||
| 458 | ``` | ||
| 459 | NAME URL TYPE STATUS | ||
| 460 | api https://api.example.com docker running | ||
| 461 | preview-x7k https://preview-x7k.example.com static running (expires in 23h) | ||
| 462 | ``` | ||
| 463 | |||
| 464 | ## Implementation Notes | ||
| 465 | |||
| 466 | ### SSH Execution | ||
| 467 | |||
| 468 | Use a single SSH connection with multiplexing: | ||
| 469 | |||
| 470 | ```bash | ||
| 471 | ssh -o ControlMaster=auto -o ControlPath=/tmp/ship-%r@%h:%p -o ControlPersist=60 ... | ||
| 472 | ``` | ||
| 473 | |||
| 474 | ### Concurrency | ||
| 475 | |||
| 476 | Ship is not designed for concurrent deploys of the same name. Behavior is undefined. | ||
| 477 | |||
| 478 | Different names can deploy concurrently. | ||
| 479 | |||
| 480 | ### Rollback (Future) | ||
| 481 | |||
| 482 | Not in v2.0. Future consideration: keep last N versions, `ship rollback <name>`. | ||
| 483 | |||
| 484 | --- | ||
| 485 | |||
| 486 | ## Appendix: Example Session | ||
| 487 | |||
| 488 | ```bash | ||
| 489 | $ mkdir -p /tmp/hello && echo '<h1>Hello</h1>' > /tmp/hello/index.html | ||
| 490 | |||
| 491 | $ ship /tmp/hello --name hello | ||
| 492 | {"status":"ok","name":"hello","url":"https://hello.example.com","type":"static","took_ms":3200,"health":{"endpoint":"/","status":200,"latency_ms":45}} | ||
| 493 | |||
| 494 | $ ship list | ||
| 495 | {"status":"ok","deploys":[{"name":"hello","url":"https://hello.example.com","type":"static","running":true}]} | ||
| 496 | |||
| 497 | $ ship remove hello | ||
| 498 | {"status":"ok","name":"hello","removed":true} | ||
| 499 | ``` | ||
| @@ -0,0 +1,44 @@ | |||
| 1 | # Ship TODO | ||
| 2 | |||
| 3 | ## Open | ||
| 4 | |||
| 5 | ### ~~Support custom Caddyfile~~ ✅ FIXED | ||
| 6 | **Fixed in:** commit b976b14 | ||
| 7 | |||
| 8 | - Caddyfile is only generated on first deploy | ||
| 9 | - Redeploys preserve existing Caddyfile (manual edits survive) | ||
| 10 | - SSH in and customize as needed, future deploys won't overwrite | ||
| 11 | |||
| 12 | --- | ||
| 13 | |||
| 14 | ### ~~ship list should show custom domains~~ ✅ FIXED | ||
| 15 | **Fixed in:** commit c1b0bb8 | ||
| 16 | |||
| 17 | - Reads actual domain from Caddyfile instead of assuming subdomain | ||
| 18 | - Works for both apps and static sites | ||
| 19 | |||
| 20 | --- | ||
| 21 | |||
| 22 | ## Fixed | ||
| 23 | |||
| 24 | ### ~~Port allocation collision bug~~ ✅ FIXED | ||
| 25 | **Fixed in:** commit d97bb6f | ||
| 26 | |||
| 27 | - Port allocation now uses atomic flock on `/etc/ship/next_port` | ||
| 28 | - Prevents race conditions when multiple deploys run concurrently | ||
| 29 | - Each app still gets its port stored in `/etc/ship/ports/<name>` | ||
| 30 | |||
| 31 | ### ~~Docker container port assumption~~ ✅ FIXED | ||
| 32 | **Fixed in:** commit d97bb6f | ||
| 33 | |||
| 34 | - Added `--container-port` flag (default 80) | ||
| 35 | - Template now uses `{{.ContainerPort}}` instead of hardcoded 80 | ||
| 36 | - Supports containers that listen on 8080, 3000, etc. | ||
| 37 | |||
| 38 | ### ~~ship host init doesn't create local state.json~~ ✅ FIXED | ||
| 39 | **Fixed in:** commit 6b2c047 | ||
| 40 | |||
| 41 | - Removed all v1 code (-2837 lines) | ||
| 42 | - Simplified `internal/state/state.go` to just `default_host` + `base_domain` | ||
| 43 | - `host init` now creates minimal state.json | ||
| 44 | - Ports/deploys tracked on server at `/etc/ship/ports/` | ||
diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go new file mode 100644 index 0000000..1b0d09c --- /dev/null +++ b/cmd/ship/commands_v2.go | |||
| @@ -0,0 +1,365 @@ | |||
| 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 414ade5..0000000 --- a/cmd/ship/deploy.go +++ /dev/null | |||
| @@ -1,664 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strconv" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/ssh" | ||
| 12 | "github.com/bdw/ship/internal/state" | ||
| 13 | "github.com/bdw/ship/internal/templates" | ||
| 14 | "github.com/spf13/cobra" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // DeployOptions contains all options for deploying or updating an app | ||
| 18 | type DeployOptions struct { | ||
| 19 | Host string | ||
| 20 | Domain string | ||
| 21 | Name string | ||
| 22 | Binary string | ||
| 23 | Dir string // for static sites | ||
| 24 | Port int | ||
| 25 | Args string | ||
| 26 | Files []string | ||
| 27 | Memory string | ||
| 28 | CPU string | ||
| 29 | Env map[string]string // merged env vars | ||
| 30 | IsUpdate bool | ||
| 31 | } | ||
| 32 | |||
| 33 | func runDeploy(cmd *cobra.Command, args []string) error { | ||
| 34 | flags := cmd.Flags() | ||
| 35 | |||
| 36 | // Parse CLI flags | ||
| 37 | binary, _ := flags.GetString("binary") | ||
| 38 | static, _ := flags.GetBool("static") | ||
| 39 | dir, _ := flags.GetString("dir") | ||
| 40 | domain, _ := flags.GetString("domain") | ||
| 41 | name, _ := flags.GetString("name") | ||
| 42 | port, _ := flags.GetInt("port") | ||
| 43 | envVars, _ := flags.GetStringArray("env") | ||
| 44 | envFile, _ := flags.GetString("env-file") | ||
| 45 | argsFlag, _ := flags.GetString("args") | ||
| 46 | files, _ := flags.GetStringArray("file") | ||
| 47 | memory, _ := flags.GetString("memory") | ||
| 48 | cpu, _ := flags.GetString("cpu") | ||
| 49 | |||
| 50 | // Get host from flag or state default | ||
| 51 | host := hostFlag | ||
| 52 | if host == "" { | ||
| 53 | st, err := state.Load() | ||
| 54 | if err != nil { | ||
| 55 | return fmt.Errorf("error loading state: %w", err) | ||
| 56 | } | ||
| 57 | host = st.GetDefaultHost() | ||
| 58 | } | ||
| 59 | |||
| 60 | // If no flags provided, show help | ||
| 61 | if domain == "" && binary == "" && !static && name == "" { | ||
| 62 | return cmd.Help() | ||
| 63 | } | ||
| 64 | |||
| 65 | if host == "" { | ||
| 66 | return fmt.Errorf("--host is required") | ||
| 67 | } | ||
| 68 | |||
| 69 | // Load state once - this will be used throughout | ||
| 70 | st, err := state.Load() | ||
| 71 | if err != nil { | ||
| 72 | return fmt.Errorf("error loading state: %w", err) | ||
| 73 | } | ||
| 74 | hostState := st.GetHost(host) | ||
| 75 | |||
| 76 | // Config update mode: --name provided without --binary or --static | ||
| 77 | if name != "" && binary == "" && !static { | ||
| 78 | existingApp, err := st.GetApp(host, name) | ||
| 79 | if err != nil { | ||
| 80 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) | ||
| 81 | } | ||
| 82 | |||
| 83 | // Build merged config starting from existing app | ||
| 84 | opts := DeployOptions{ | ||
| 85 | Host: host, | ||
| 86 | Name: name, | ||
| 87 | Port: existingApp.Port, | ||
| 88 | Args: existingApp.Args, | ||
| 89 | Files: existingApp.Files, | ||
| 90 | Memory: existingApp.Memory, | ||
| 91 | CPU: existingApp.CPU, | ||
| 92 | Env: make(map[string]string), | ||
| 93 | } | ||
| 94 | for k, v := range existingApp.Env { | ||
| 95 | opts.Env[k] = v | ||
| 96 | } | ||
| 97 | |||
| 98 | // Override with CLI flags if provided | ||
| 99 | if argsFlag != "" { | ||
| 100 | opts.Args = argsFlag | ||
| 101 | } | ||
| 102 | if len(files) > 0 { | ||
| 103 | opts.Files = files | ||
| 104 | } | ||
| 105 | if memory != "" { | ||
| 106 | opts.Memory = memory | ||
| 107 | } | ||
| 108 | if cpu != "" { | ||
| 109 | opts.CPU = cpu | ||
| 110 | } | ||
| 111 | |||
| 112 | // Merge env vars (CLI overrides existing) | ||
| 113 | for _, e := range envVars { | ||
| 114 | parts := strings.SplitN(e, "=", 2) | ||
| 115 | if len(parts) == 2 { | ||
| 116 | opts.Env[parts[0]] = parts[1] | ||
| 117 | } | ||
| 118 | } | ||
| 119 | if envFile != "" { | ||
| 120 | fileEnv, err := parseEnvFile(envFile) | ||
| 121 | if err != nil { | ||
| 122 | return fmt.Errorf("error reading env file: %w", err) | ||
| 123 | } | ||
| 124 | for k, v := range fileEnv { | ||
| 125 | opts.Env[k] = v | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | return updateAppConfig(st, opts) | ||
| 130 | } | ||
| 131 | |||
| 132 | // Infer name early so we can use it for subdomain generation and existing app lookup | ||
| 133 | if name == "" { | ||
| 134 | if static { | ||
| 135 | name = domain | ||
| 136 | if name == "" && hostState.BaseDomain != "" { | ||
| 137 | name = filepath.Base(dir) | ||
| 138 | } | ||
| 139 | } else { | ||
| 140 | name = filepath.Base(binary) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | if err := validateName(name); err != nil { | ||
| 144 | return err | ||
| 145 | } | ||
| 146 | |||
| 147 | // Check if this is an update to an existing app/site | ||
| 148 | existingApp, _ := st.GetApp(host, name) | ||
| 149 | isUpdate := existingApp != nil | ||
| 150 | |||
| 151 | // For new deployments, require domain or base domain | ||
| 152 | if !isUpdate && domain == "" && hostState.BaseDomain == "" { | ||
| 153 | return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") | ||
| 154 | } | ||
| 155 | |||
| 156 | // Build merged config, starting from existing app if updating | ||
| 157 | opts := DeployOptions{ | ||
| 158 | Host: host, | ||
| 159 | Name: name, | ||
| 160 | Binary: binary, | ||
| 161 | Dir: dir, | ||
| 162 | IsUpdate: isUpdate, | ||
| 163 | } | ||
| 164 | |||
| 165 | // Merge domain: auto-subdomain + (user-provided or existing custom domain) | ||
| 166 | var domains []string | ||
| 167 | if hostState.BaseDomain != "" { | ||
| 168 | domains = append(domains, name+"."+hostState.BaseDomain) | ||
| 169 | } | ||
| 170 | if domain != "" { | ||
| 171 | domains = append(domains, domain) | ||
| 172 | } else if isUpdate && existingApp.Domain != "" { | ||
| 173 | for _, d := range strings.Split(existingApp.Domain, ",") { | ||
| 174 | d = strings.TrimSpace(d) | ||
| 175 | if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { | ||
| 176 | domains = append(domains, d) | ||
| 177 | } | ||
| 178 | } | ||
| 179 | } | ||
| 180 | opts.Domain = strings.Join(domains, ", ") | ||
| 181 | |||
| 182 | // For apps, merge all config fields | ||
| 183 | if !static { | ||
| 184 | // Start with existing values if updating | ||
| 185 | if isUpdate { | ||
| 186 | opts.Port = existingApp.Port | ||
| 187 | opts.Args = existingApp.Args | ||
| 188 | opts.Files = existingApp.Files | ||
| 189 | opts.Memory = existingApp.Memory | ||
| 190 | opts.CPU = existingApp.CPU | ||
| 191 | opts.Env = make(map[string]string) | ||
| 192 | for k, v := range existingApp.Env { | ||
| 193 | opts.Env[k] = v | ||
| 194 | } | ||
| 195 | } else { | ||
| 196 | opts.Port = port | ||
| 197 | opts.Env = make(map[string]string) | ||
| 198 | } | ||
| 199 | |||
| 200 | // Override with CLI flags if provided | ||
| 201 | if argsFlag != "" { | ||
| 202 | opts.Args = argsFlag | ||
| 203 | } | ||
| 204 | if len(files) > 0 { | ||
| 205 | opts.Files = files | ||
| 206 | } | ||
| 207 | if memory != "" { | ||
| 208 | opts.Memory = memory | ||
| 209 | } | ||
| 210 | if cpu != "" { | ||
| 211 | opts.CPU = cpu | ||
| 212 | } | ||
| 213 | |||
| 214 | // Merge env vars (CLI overrides existing) | ||
| 215 | for _, e := range envVars { | ||
| 216 | parts := strings.SplitN(e, "=", 2) | ||
| 217 | if len(parts) == 2 { | ||
| 218 | opts.Env[parts[0]] = parts[1] | ||
| 219 | } | ||
| 220 | } | ||
| 221 | if envFile != "" { | ||
| 222 | fileEnv, err := parseEnvFile(envFile) | ||
| 223 | if err != nil { | ||
| 224 | return fmt.Errorf("error reading env file: %w", err) | ||
| 225 | } | ||
| 226 | for k, v := range fileEnv { | ||
| 227 | opts.Env[k] = v | ||
| 228 | } | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | if static { | ||
| 233 | return deployStatic(st, opts) | ||
| 234 | } | ||
| 235 | return deployApp(st, opts) | ||
| 236 | } | ||
| 237 | |||
| 238 | func deployApp(st *state.State, opts DeployOptions) error { | ||
| 239 | if opts.Binary == "" { | ||
| 240 | return fmt.Errorf("--binary is required") | ||
| 241 | } | ||
| 242 | |||
| 243 | if _, err := os.Stat(opts.Binary); err != nil { | ||
| 244 | return fmt.Errorf("binary not found: %s", opts.Binary) | ||
| 245 | } | ||
| 246 | |||
| 247 | fmt.Printf("Deploying app: %s\n", opts.Name) | ||
| 248 | fmt.Printf(" Domain(s): %s\n", opts.Domain) | ||
| 249 | fmt.Printf(" Binary: %s\n", opts.Binary) | ||
| 250 | |||
| 251 | // Allocate port for new apps | ||
| 252 | port := opts.Port | ||
| 253 | if opts.IsUpdate { | ||
| 254 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | ||
| 255 | } else { | ||
| 256 | if port == 0 { | ||
| 257 | port = st.AllocatePort(opts.Host) | ||
| 258 | } | ||
| 259 | fmt.Printf(" Allocated port: %d\n", port) | ||
| 260 | } | ||
| 261 | |||
| 262 | // Add PORT to env | ||
| 263 | opts.Env["PORT"] = strconv.Itoa(port) | ||
| 264 | |||
| 265 | client, err := ssh.Connect(opts.Host) | ||
| 266 | if err != nil { | ||
| 267 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 268 | } | ||
| 269 | defer client.Close() | ||
| 270 | |||
| 271 | fmt.Println("-> Uploading binary...") | ||
| 272 | remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name) | ||
| 273 | if err := client.Upload(opts.Binary, remoteTmpPath); err != nil { | ||
| 274 | return fmt.Errorf("error uploading binary: %w", err) | ||
| 275 | } | ||
| 276 | |||
| 277 | fmt.Println("-> Creating system user...") | ||
| 278 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name)) | ||
| 279 | |||
| 280 | fmt.Println("-> Setting up directories...") | ||
| 281 | workDir := fmt.Sprintf("/var/lib/%s", opts.Name) | ||
| 282 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 283 | return fmt.Errorf("error creating work directory: %w", err) | ||
| 284 | } | ||
| 285 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil { | ||
| 286 | return fmt.Errorf("error setting work directory ownership: %w", err) | ||
| 287 | } | ||
| 288 | |||
| 289 | fmt.Println("-> Installing binary...") | ||
| 290 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) | ||
| 291 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { | ||
| 292 | return fmt.Errorf("error moving binary: %w", err) | ||
| 293 | } | ||
| 294 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { | ||
| 295 | return fmt.Errorf("error making binary executable: %w", err) | ||
| 296 | } | ||
| 297 | |||
| 298 | if len(opts.Files) > 0 { | ||
| 299 | fmt.Println("-> Uploading config files...") | ||
| 300 | for _, file := range opts.Files { | ||
| 301 | if _, err := os.Stat(file); err != nil { | ||
| 302 | return fmt.Errorf("config file not found: %s", file) | ||
| 303 | } | ||
| 304 | |||
| 305 | remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) | ||
| 306 | fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file)) | ||
| 307 | |||
| 308 | if err := client.Upload(file, fileTmpPath); err != nil { | ||
| 309 | return fmt.Errorf("error uploading config file %s: %w", file, err) | ||
| 310 | } | ||
| 311 | |||
| 312 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil { | ||
| 313 | return fmt.Errorf("error moving config file %s: %w", file, err) | ||
| 314 | } | ||
| 315 | |||
| 316 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil { | ||
| 317 | return fmt.Errorf("error setting config file ownership %s: %w", file, err) | ||
| 318 | } | ||
| 319 | |||
| 320 | fmt.Printf(" Uploaded: %s\n", file) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | fmt.Println("-> Creating environment file...") | ||
| 325 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) | ||
| 326 | envContent := "" | ||
| 327 | for k, v := range opts.Env { | ||
| 328 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 329 | } | ||
| 330 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 331 | return fmt.Errorf("error creating env file: %w", err) | ||
| 332 | } | ||
| 333 | if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { | ||
| 334 | return fmt.Errorf("error setting env file permissions: %w", err) | ||
| 335 | } | ||
| 336 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil { | ||
| 337 | return fmt.Errorf("error setting env file ownership: %w", err) | ||
| 338 | } | ||
| 339 | |||
| 340 | // Create local .ship directory for deployment configs if they don't exist | ||
| 341 | // (handles both initial deployment and migration of existing deployments) | ||
| 342 | if _, err := os.Stat(".ship/service"); os.IsNotExist(err) { | ||
| 343 | fmt.Println("-> Creating local .ship directory...") | ||
| 344 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 345 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 346 | } | ||
| 347 | |||
| 348 | fmt.Println("-> Generating systemd service...") | ||
| 349 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 350 | "Name": opts.Name, | ||
| 351 | "User": opts.Name, | ||
| 352 | "WorkDir": workDir, | ||
| 353 | "BinaryPath": binaryDest, | ||
| 354 | "Port": strconv.Itoa(port), | ||
| 355 | "EnvFile": envFilePath, | ||
| 356 | "Args": opts.Args, | ||
| 357 | "Memory": opts.Memory, | ||
| 358 | "CPU": opts.CPU, | ||
| 359 | }) | ||
| 360 | if err != nil { | ||
| 361 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 362 | } | ||
| 363 | if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil { | ||
| 364 | return fmt.Errorf("error writing .ship/service: %w", err) | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | if _, err := os.Stat(".ship/Caddyfile"); os.IsNotExist(err) { | ||
| 369 | fmt.Println("-> Generating Caddyfile...") | ||
| 370 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 371 | "Domain": opts.Domain, | ||
| 372 | "Port": strconv.Itoa(port), | ||
| 373 | }) | ||
| 374 | if err != nil { | ||
| 375 | return fmt.Errorf("error generating Caddy config: %w", err) | ||
| 376 | } | ||
| 377 | if err := os.WriteFile(".ship/Caddyfile", []byte(caddyContent), 0644); err != nil { | ||
| 378 | return fmt.Errorf("error writing .ship/Caddyfile: %w", err) | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | // Upload systemd service from .ship/service | ||
| 383 | fmt.Println("-> Installing systemd service...") | ||
| 384 | serviceContent, err := os.ReadFile(".ship/service") | ||
| 385 | if err != nil { | ||
| 386 | return fmt.Errorf("error reading .ship/service: %w", err) | ||
| 387 | } | ||
| 388 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) | ||
| 389 | if err := client.WriteSudoFile(servicePath, string(serviceContent)); err != nil { | ||
| 390 | return fmt.Errorf("error installing systemd unit: %w", err) | ||
| 391 | } | ||
| 392 | |||
| 393 | // Upload Caddyfile from .ship/Caddyfile | ||
| 394 | fmt.Println("-> Installing Caddy config...") | ||
| 395 | caddyContent, err := os.ReadFile(".ship/Caddyfile") | ||
| 396 | if err != nil { | ||
| 397 | return fmt.Errorf("error reading .ship/Caddyfile: %w", err) | ||
| 398 | } | ||
| 399 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) | ||
| 400 | if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil { | ||
| 401 | return fmt.Errorf("error installing Caddy config: %w", err) | ||
| 402 | } | ||
| 403 | |||
| 404 | fmt.Println("-> Reloading systemd...") | ||
| 405 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 406 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 407 | } | ||
| 408 | |||
| 409 | fmt.Println("-> Starting service...") | ||
| 410 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil { | ||
| 411 | return fmt.Errorf("error enabling service: %w", err) | ||
| 412 | } | ||
| 413 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { | ||
| 414 | return fmt.Errorf("error starting service: %w", err) | ||
| 415 | } | ||
| 416 | |||
| 417 | fmt.Println("-> Reloading Caddy...") | ||
| 418 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 419 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 420 | } | ||
| 421 | |||
| 422 | st.AddApp(opts.Host, opts.Name, &state.App{ | ||
| 423 | Type: "app", | ||
| 424 | Domain: opts.Domain, | ||
| 425 | Port: port, | ||
| 426 | Env: opts.Env, | ||
| 427 | Args: opts.Args, | ||
| 428 | Files: opts.Files, | ||
| 429 | Memory: opts.Memory, | ||
| 430 | CPU: opts.CPU, | ||
| 431 | }) | ||
| 432 | if err := st.Save(); err != nil { | ||
| 433 | return fmt.Errorf("error saving state: %w", err) | ||
| 434 | } | ||
| 435 | |||
| 436 | fmt.Printf("\n App deployed successfully!\n") | ||
| 437 | // Show first domain in the URL message | ||
| 438 | primaryDomain := strings.Split(opts.Domain, ",")[0] | ||
| 439 | primaryDomain = strings.TrimSpace(primaryDomain) | ||
| 440 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) | ||
| 441 | return nil | ||
| 442 | } | ||
| 443 | |||
| 444 | func updateAppConfig(st *state.State, opts DeployOptions) error { | ||
| 445 | existingApp, err := st.GetApp(opts.Host, opts.Name) | ||
| 446 | if err != nil { | ||
| 447 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) | ||
| 448 | } | ||
| 449 | |||
| 450 | if existingApp.Type != "app" && existingApp.Type != "git-app" { | ||
| 451 | return fmt.Errorf("%s is a static site, not an app", opts.Name) | ||
| 452 | } | ||
| 453 | |||
| 454 | fmt.Printf("Updating config: %s\n", opts.Name) | ||
| 455 | |||
| 456 | // Add PORT to env | ||
| 457 | opts.Env["PORT"] = strconv.Itoa(existingApp.Port) | ||
| 458 | |||
| 459 | client, err := ssh.Connect(opts.Host) | ||
| 460 | if err != nil { | ||
| 461 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 462 | } | ||
| 463 | defer client.Close() | ||
| 464 | |||
| 465 | // Update env file | ||
| 466 | fmt.Println("-> Updating environment file...") | ||
| 467 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) | ||
| 468 | envContent := "" | ||
| 469 | for k, v := range opts.Env { | ||
| 470 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 471 | } | ||
| 472 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 473 | return fmt.Errorf("error creating env file: %w", err) | ||
| 474 | } | ||
| 475 | |||
| 476 | // For git-app, the systemd unit comes from .ship/service in the repo, | ||
| 477 | // so we only update the env file and restart. | ||
| 478 | if existingApp.Type != "git-app" { | ||
| 479 | // Regenerate systemd unit to .ship/service (resource flags are being updated) | ||
| 480 | fmt.Println("-> Updating systemd service...") | ||
| 481 | workDir := fmt.Sprintf("/var/lib/%s", opts.Name) | ||
| 482 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) | ||
| 483 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 484 | "Name": opts.Name, | ||
| 485 | "User": opts.Name, | ||
| 486 | "WorkDir": workDir, | ||
| 487 | "BinaryPath": binaryDest, | ||
| 488 | "Port": strconv.Itoa(existingApp.Port), | ||
| 489 | "EnvFile": envFilePath, | ||
| 490 | "Args": opts.Args, | ||
| 491 | "Memory": opts.Memory, | ||
| 492 | "CPU": opts.CPU, | ||
| 493 | }) | ||
| 494 | if err != nil { | ||
| 495 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 496 | } | ||
| 497 | |||
| 498 | // Write to local .ship/service | ||
| 499 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 500 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 501 | } | ||
| 502 | if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil { | ||
| 503 | return fmt.Errorf("error writing .ship/service: %w", err) | ||
| 504 | } | ||
| 505 | |||
| 506 | // Upload to server | ||
| 507 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) | ||
| 508 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 509 | return fmt.Errorf("error installing systemd unit: %w", err) | ||
| 510 | } | ||
| 511 | |||
| 512 | fmt.Println("-> Reloading systemd...") | ||
| 513 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 514 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 515 | } | ||
| 516 | } | ||
| 517 | |||
| 518 | fmt.Println("-> Restarting service...") | ||
| 519 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { | ||
| 520 | return fmt.Errorf("error restarting service: %w", err) | ||
| 521 | } | ||
| 522 | |||
| 523 | // Update state | ||
| 524 | existingApp.Args = opts.Args | ||
| 525 | existingApp.Memory = opts.Memory | ||
| 526 | existingApp.CPU = opts.CPU | ||
| 527 | existingApp.Env = opts.Env | ||
| 528 | if err := st.Save(); err != nil { | ||
| 529 | return fmt.Errorf("error saving state: %w", err) | ||
| 530 | } | ||
| 531 | |||
| 532 | fmt.Printf("\n Config updated successfully!\n") | ||
| 533 | return nil | ||
| 534 | } | ||
| 535 | |||
| 536 | func deployStatic(st *state.State, opts DeployOptions) error { | ||
| 537 | if _, err := os.Stat(opts.Dir); err != nil { | ||
| 538 | return fmt.Errorf("directory not found: %s", opts.Dir) | ||
| 539 | } | ||
| 540 | |||
| 541 | fmt.Printf("Deploying static site: %s\n", opts.Name) | ||
| 542 | fmt.Printf(" Domain(s): %s\n", opts.Domain) | ||
| 543 | fmt.Printf(" Directory: %s\n", opts.Dir) | ||
| 544 | |||
| 545 | if opts.IsUpdate { | ||
| 546 | fmt.Println(" Updating existing deployment") | ||
| 547 | } | ||
| 548 | |||
| 549 | client, err := ssh.Connect(opts.Host) | ||
| 550 | if err != nil { | ||
| 551 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 552 | } | ||
| 553 | defer client.Close() | ||
| 554 | |||
| 555 | remoteDir := fmt.Sprintf("/var/www/%s", opts.Name) | ||
| 556 | fmt.Println("-> Creating remote directory...") | ||
| 557 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | ||
| 558 | return fmt.Errorf("error creating remote directory: %w", err) | ||
| 559 | } | ||
| 560 | |||
| 561 | currentUser, err := client.Run("whoami") | ||
| 562 | if err != nil { | ||
| 563 | return fmt.Errorf("error getting current user: %w", err) | ||
| 564 | } | ||
| 565 | currentUser = strings.TrimSpace(currentUser) | ||
| 566 | |||
| 567 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { | ||
| 568 | return fmt.Errorf("error setting temporary ownership: %w", err) | ||
| 569 | } | ||
| 570 | |||
| 571 | fmt.Println("-> Uploading files...") | ||
| 572 | if err := client.UploadDir(opts.Dir, remoteDir); err != nil { | ||
| 573 | return fmt.Errorf("error uploading files: %w", err) | ||
| 574 | } | ||
| 575 | |||
| 576 | fmt.Println("-> Setting permissions...") | ||
| 577 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { | ||
| 578 | return fmt.Errorf("error setting ownership: %w", err) | ||
| 579 | } | ||
| 580 | if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { | ||
| 581 | return fmt.Errorf("error setting directory permissions: %w", err) | ||
| 582 | } | ||
| 583 | if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { | ||
| 584 | return fmt.Errorf("error setting file permissions: %w", err) | ||
| 585 | } | ||
| 586 | |||
| 587 | // Create local .ship directory and Caddyfile for static sites if it doesn't exist | ||
| 588 | // (handles both initial deployment and migration of existing deployments) | ||
| 589 | shipDir := filepath.Join(opts.Dir, ".ship") | ||
| 590 | caddyfilePath := filepath.Join(shipDir, "Caddyfile") | ||
| 591 | |||
| 592 | if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) { | ||
| 593 | fmt.Println("-> Creating local .ship directory...") | ||
| 594 | if err := os.MkdirAll(shipDir, 0755); err != nil { | ||
| 595 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 596 | } | ||
| 597 | |||
| 598 | fmt.Println("-> Generating Caddyfile...") | ||
| 599 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 600 | "Domain": opts.Domain, | ||
| 601 | "RootDir": remoteDir, | ||
| 602 | }) | ||
| 603 | if err != nil { | ||
| 604 | return fmt.Errorf("error generating Caddy config: %w", err) | ||
| 605 | } | ||
| 606 | if err := os.WriteFile(caddyfilePath, []byte(caddyContent), 0644); err != nil { | ||
| 607 | return fmt.Errorf("error writing .ship/Caddyfile: %w", err) | ||
| 608 | } | ||
| 609 | } | ||
| 610 | |||
| 611 | // Upload Caddyfile from .ship/Caddyfile | ||
| 612 | fmt.Println("-> Installing Caddy config...") | ||
| 613 | caddyContent, err := os.ReadFile(caddyfilePath) | ||
| 614 | if err != nil { | ||
| 615 | return fmt.Errorf("error reading .ship/Caddyfile: %w", err) | ||
| 616 | } | ||
| 617 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) | ||
| 618 | if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil { | ||
| 619 | return fmt.Errorf("error installing Caddy config: %w", err) | ||
| 620 | } | ||
| 621 | |||
| 622 | fmt.Println("-> Reloading Caddy...") | ||
| 623 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 624 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 625 | } | ||
| 626 | |||
| 627 | st.AddApp(opts.Host, opts.Name, &state.App{ | ||
| 628 | Type: "static", | ||
| 629 | Domain: opts.Domain, | ||
| 630 | }) | ||
| 631 | if err := st.Save(); err != nil { | ||
| 632 | return fmt.Errorf("error saving state: %w", err) | ||
| 633 | } | ||
| 634 | |||
| 635 | fmt.Printf("\n Static site deployed successfully!\n") | ||
| 636 | primaryDomain := strings.Split(opts.Domain, ",")[0] | ||
| 637 | primaryDomain = strings.TrimSpace(primaryDomain) | ||
| 638 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) | ||
| 639 | return nil | ||
| 640 | } | ||
| 641 | |||
| 642 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 643 | file, err := os.Open(path) | ||
| 644 | if err != nil { | ||
| 645 | return nil, err | ||
| 646 | } | ||
| 647 | defer file.Close() | ||
| 648 | |||
| 649 | env := make(map[string]string) | ||
| 650 | scanner := bufio.NewScanner(file) | ||
| 651 | for scanner.Scan() { | ||
| 652 | line := strings.TrimSpace(scanner.Text()) | ||
| 653 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 654 | continue | ||
| 655 | } | ||
| 656 | |||
| 657 | parts := strings.SplitN(line, "=", 2) | ||
| 658 | if len(parts) == 2 { | ||
| 659 | env[parts[0]] = parts[1] | ||
| 660 | } | ||
| 661 | } | ||
| 662 | |||
| 663 | return env, scanner.Err() | ||
| 664 | } | ||
diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go deleted file mode 100644 index ba45c4f..0000000 --- a/cmd/ship/deploy_cmd.go +++ /dev/null | |||
| @@ -1,141 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var deployGitCmd = &cobra.Command{ | ||
| 12 | Use: "deploy <name>", | ||
| 13 | Short: "Manually rebuild and deploy a git-deployed app", | ||
| 14 | Long: `Trigger a manual rebuild from the latest code in the git repo. | ||
| 15 | |||
| 16 | This runs the same steps as the post-receive hook: checkout code, | ||
| 17 | install .ship/ configs, docker build (for apps), and restart. | ||
| 18 | |||
| 19 | Examples: | ||
| 20 | ship deploy myapp`, | ||
| 21 | Args: cobra.ExactArgs(1), | ||
| 22 | RunE: runDeployGit, | ||
| 23 | } | ||
| 24 | |||
| 25 | func runDeployGit(cmd *cobra.Command, args []string) error { | ||
| 26 | name := args[0] | ||
| 27 | if err := validateName(name); err != nil { | ||
| 28 | return err | ||
| 29 | } | ||
| 30 | |||
| 31 | st, err := state.Load() | ||
| 32 | if err != nil { | ||
| 33 | return fmt.Errorf("error loading state: %w", err) | ||
| 34 | } | ||
| 35 | |||
| 36 | host := hostFlag | ||
| 37 | if host == "" { | ||
| 38 | host = st.GetDefaultHost() | ||
| 39 | } | ||
| 40 | if host == "" { | ||
| 41 | return fmt.Errorf("--host is required") | ||
| 42 | } | ||
| 43 | |||
| 44 | app, err := st.GetApp(host, name) | ||
| 45 | if err != nil { | ||
| 46 | return err | ||
| 47 | } | ||
| 48 | |||
| 49 | if app.Type != "git-app" && app.Type != "git-static" { | ||
| 50 | return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type) | ||
| 51 | } | ||
| 52 | |||
| 53 | fmt.Printf("Deploying %s...\n", name) | ||
| 54 | |||
| 55 | client, err := ssh.Connect(host) | ||
| 56 | if err != nil { | ||
| 57 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 58 | } | ||
| 59 | defer client.Close() | ||
| 60 | |||
| 61 | if app.Type == "git-app" { | ||
| 62 | if err := deployGitApp(client, name); err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | } else { | ||
| 66 | if err := deployGitStatic(client, name); err != nil { | ||
| 67 | return err | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | fmt.Println("\nDeploy complete!") | ||
| 72 | return nil | ||
| 73 | } | ||
| 74 | |||
| 75 | func deployGitApp(client *ssh.Client, name string) error { | ||
| 76 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 77 | src := fmt.Sprintf("/var/lib/%s/src", name) | ||
| 78 | |||
| 79 | fmt.Println("-> Checking out code...") | ||
| 80 | if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil { | ||
| 81 | return fmt.Errorf("error checking out code: %w", err) | ||
| 82 | } | ||
| 83 | |||
| 84 | // Install deployment config from repo | ||
| 85 | serviceSrc := fmt.Sprintf("%s/.ship/service", src) | ||
| 86 | serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 87 | fmt.Println("-> Installing systemd unit...") | ||
| 88 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil { | ||
| 89 | fmt.Printf(" Warning: no .ship/service found, skipping\n") | ||
| 90 | } else { | ||
| 91 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 92 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src) | ||
| 97 | caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 98 | fmt.Println("-> Installing Caddy config...") | ||
| 99 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { | ||
| 100 | fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") | ||
| 101 | } else { | ||
| 102 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 103 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | fmt.Println("-> Building Docker image...") | ||
| 108 | if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil { | ||
| 109 | return fmt.Errorf("error building Docker image: %w", err) | ||
| 110 | } | ||
| 111 | |||
| 112 | fmt.Println("-> Restarting service...") | ||
| 113 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 114 | return fmt.Errorf("error restarting service: %w", err) | ||
| 115 | } | ||
| 116 | |||
| 117 | return nil | ||
| 118 | } | ||
| 119 | |||
| 120 | func deployGitStatic(client *ssh.Client, name string) error { | ||
| 121 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 122 | webroot := fmt.Sprintf("/var/www/%s", name) | ||
| 123 | |||
| 124 | fmt.Println("-> Deploying static site...") | ||
| 125 | if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil { | ||
| 126 | return fmt.Errorf("error checking out code: %w", err) | ||
| 127 | } | ||
| 128 | |||
| 129 | caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot) | ||
| 130 | caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 131 | fmt.Println("-> Installing Caddy config...") | ||
| 132 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { | ||
| 133 | fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") | ||
| 134 | } else { | ||
| 135 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 136 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | return nil | ||
| 141 | } | ||
diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go new file mode 100644 index 0000000..5b68dc3 --- /dev/null +++ b/cmd/ship/deploy_impl_v2.go | |||
| @@ -0,0 +1,397 @@ | |||
| 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 | // Generate and write env file | ||
| 124 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 125 | for _, e := range ctx.Opts.Env { | ||
| 126 | envContent += e + "\n" | ||
| 127 | } | ||
| 128 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 129 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 130 | // Continue, directory might exist | ||
| 131 | } | ||
| 132 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 133 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 134 | } | ||
| 135 | |||
| 136 | // Generate systemd unit | ||
| 137 | containerPort := ctx.Opts.ContainerPort | ||
| 138 | if containerPort == 0 { | ||
| 139 | containerPort = 80 | ||
| 140 | } | ||
| 141 | service, err := templates.DockerService(map[string]string{ | ||
| 142 | "Name": name, | ||
| 143 | "Port": strconv.Itoa(port), | ||
| 144 | "ContainerPort": strconv.Itoa(containerPort), | ||
| 145 | }) | ||
| 146 | if err != nil { | ||
| 147 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 148 | } | ||
| 149 | |||
| 150 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 151 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 152 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 153 | } | ||
| 154 | |||
| 155 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 156 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 157 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 158 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 159 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 160 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 161 | "Port": strconv.Itoa(port), | ||
| 162 | }) | ||
| 163 | if err != nil { | ||
| 164 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 165 | } | ||
| 166 | |||
| 167 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 168 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | // Reload systemd and start service | ||
| 173 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 174 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 175 | } | ||
| 176 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 177 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Reload Caddy | ||
| 181 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 182 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 183 | } | ||
| 184 | |||
| 185 | return nil | ||
| 186 | } | ||
| 187 | |||
| 188 | // deployBinaryV2 deploys a pre-built binary | ||
| 189 | // 1. Allocate port | ||
| 190 | // 2. scp binary to /usr/local/bin/<name> | ||
| 191 | // 3. Create user for service | ||
| 192 | // 4. Generate systemd unit and env file | ||
| 193 | // 5. Generate Caddyfile | ||
| 194 | // 6. Start service, reload Caddy | ||
| 195 | func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { | ||
| 196 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 197 | if err != nil { | ||
| 198 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 199 | } | ||
| 200 | defer client.Close() | ||
| 201 | |||
| 202 | name := ctx.Name | ||
| 203 | |||
| 204 | // Allocate port on server | ||
| 205 | port, err := allocatePort(client, name) | ||
| 206 | if err != nil { | ||
| 207 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 208 | } | ||
| 209 | |||
| 210 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 211 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 212 | |||
| 213 | // Upload binary | ||
| 214 | if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { | ||
| 215 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 216 | } | ||
| 217 | |||
| 218 | // Move to final location and set permissions | ||
| 219 | if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { | ||
| 220 | return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) | ||
| 221 | } | ||
| 222 | |||
| 223 | // Create work directory | ||
| 224 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 225 | return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) | ||
| 226 | } | ||
| 227 | |||
| 228 | // Create service user (ignore error if exists) | ||
| 229 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) | ||
| 230 | client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) | ||
| 231 | |||
| 232 | // Generate and write env file | ||
| 233 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 234 | for _, e := range ctx.Opts.Env { | ||
| 235 | envContent += e + "\n" | ||
| 236 | } | ||
| 237 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 238 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 239 | // Continue | ||
| 240 | } | ||
| 241 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 242 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 243 | } | ||
| 244 | |||
| 245 | // Generate systemd unit | ||
| 246 | service, err := templates.SystemdService(map[string]string{ | ||
| 247 | "Name": name, | ||
| 248 | "User": name, | ||
| 249 | "WorkDir": workDir, | ||
| 250 | "EnvFile": envPath, | ||
| 251 | "BinaryPath": binaryPath, | ||
| 252 | "Args": "", | ||
| 253 | }) | ||
| 254 | if err != nil { | ||
| 255 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 256 | } | ||
| 257 | |||
| 258 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 259 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 260 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 261 | } | ||
| 262 | |||
| 263 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 264 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 265 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 266 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 267 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 268 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 269 | "Port": strconv.Itoa(port), | ||
| 270 | }) | ||
| 271 | if err != nil { | ||
| 272 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 273 | } | ||
| 274 | |||
| 275 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 276 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 277 | } | ||
| 278 | } | ||
| 279 | |||
| 280 | // Reload systemd and start service | ||
| 281 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 282 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 283 | } | ||
| 284 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { | ||
| 285 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 286 | } | ||
| 287 | |||
| 288 | // Reload Caddy | ||
| 289 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 290 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 291 | } | ||
| 292 | |||
| 293 | return nil | ||
| 294 | } | ||
| 295 | |||
| 296 | // allocatePort allocates or retrieves a port for a service | ||
| 297 | // Uses atomic increment on /etc/ship/next_port to avoid collisions | ||
| 298 | func allocatePort(client *ssh.Client, name string) (int, error) { | ||
| 299 | portFile := fmt.Sprintf("/etc/ship/ports/%s", name) | ||
| 300 | |||
| 301 | // Try to read existing port for this app | ||
| 302 | out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) | ||
| 303 | if err == nil && out != "" { | ||
| 304 | out = strings.TrimSpace(out) | ||
| 305 | if port, err := strconv.Atoi(out); err == nil && port > 0 { | ||
| 306 | return port, nil | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | // Allocate new port atomically using flock | ||
| 311 | // This reads next_port, increments it, and writes back while holding a lock | ||
| 312 | allocScript := ` | ||
| 313 | flock -x /etc/ship/.port.lock sh -c ' | ||
| 314 | mkdir -p /etc/ship/ports | ||
| 315 | PORT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000) | ||
| 316 | echo $((PORT + 1)) > /etc/ship/next_port | ||
| 317 | echo $PORT | ||
| 318 | '` | ||
| 319 | out, err = client.RunSudo(allocScript) | ||
| 320 | if err != nil { | ||
| 321 | return 0, fmt.Errorf("failed to allocate port: %w", err) | ||
| 322 | } | ||
| 323 | |||
| 324 | port, err := strconv.Atoi(strings.TrimSpace(out)) | ||
| 325 | if err != nil { | ||
| 326 | return 0, fmt.Errorf("invalid port allocated: %s", out) | ||
| 327 | } | ||
| 328 | |||
| 329 | // Write port allocation for this app | ||
| 330 | if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { | ||
| 331 | return 0, err | ||
| 332 | } | ||
| 333 | |||
| 334 | return port, nil | ||
| 335 | } | ||
| 336 | |||
| 337 | // setTTLV2 sets auto-expiry for a deploy | ||
| 338 | func setTTLV2(ctx *deployContext, ttl time.Duration) error { | ||
| 339 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 340 | if err != nil { | ||
| 341 | return err | ||
| 342 | } | ||
| 343 | defer client.Close() | ||
| 344 | |||
| 345 | expires := time.Now().Add(ttl).Unix() | ||
| 346 | ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) | ||
| 347 | |||
| 348 | if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { | ||
| 349 | return err | ||
| 350 | } | ||
| 351 | |||
| 352 | return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) | ||
| 353 | } | ||
| 354 | |||
| 355 | // runHealthCheck verifies the deploy is responding | ||
| 356 | func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { | ||
| 357 | fullURL := url + endpoint | ||
| 358 | |||
| 359 | // Wait for app to start | ||
| 360 | time.Sleep(2 * time.Second) | ||
| 361 | |||
| 362 | var lastErr error | ||
| 363 | var lastStatus int | ||
| 364 | |||
| 365 | for i := 0; i < 15; i++ { | ||
| 366 | start := time.Now() | ||
| 367 | resp, err := http.Get(fullURL) | ||
| 368 | latency := time.Since(start).Milliseconds() | ||
| 369 | |||
| 370 | if err != nil { | ||
| 371 | lastErr = err | ||
| 372 | time.Sleep(2 * time.Second) | ||
| 373 | continue | ||
| 374 | } | ||
| 375 | resp.Body.Close() | ||
| 376 | lastStatus = resp.StatusCode | ||
| 377 | |||
| 378 | if resp.StatusCode >= 200 && resp.StatusCode < 400 { | ||
| 379 | return &output.HealthResult{ | ||
| 380 | Endpoint: endpoint, | ||
| 381 | Status: resp.StatusCode, | ||
| 382 | LatencyMs: latency, | ||
| 383 | }, nil | ||
| 384 | } | ||
| 385 | |||
| 386 | time.Sleep(2 * time.Second) | ||
| 387 | } | ||
| 388 | |||
| 389 | msg := fmt.Sprintf("health check failed after 30s: ") | ||
| 390 | if lastErr != nil { | ||
| 391 | msg += lastErr.Error() | ||
| 392 | } else { | ||
| 393 | msg += fmt.Sprintf("status %d", lastStatus) | ||
| 394 | } | ||
| 395 | |||
| 396 | return nil, output.Err(output.ErrHealthCheckFailed, msg) | ||
| 397 | } | ||
diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go new file mode 100644 index 0000000..7d498b2 --- /dev/null +++ b/cmd/ship/deploy_v2.go | |||
| @@ -0,0 +1,210 @@ | |||
| 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/env/env.go b/cmd/ship/env/env.go deleted file mode 100644 index 489353a..0000000 --- a/cmd/ship/env/env.go +++ /dev/null | |||
| @@ -1,17 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/spf13/cobra" | ||
| 5 | ) | ||
| 6 | |||
| 7 | var Cmd = &cobra.Command{ | ||
| 8 | Use: "env", | ||
| 9 | Short: "Manage environment variables", | ||
| 10 | Long: "Manage environment variables for deployed applications", | ||
| 11 | } | ||
| 12 | |||
| 13 | func init() { | ||
| 14 | Cmd.AddCommand(listCmd) | ||
| 15 | Cmd.AddCommand(setCmd) | ||
| 16 | Cmd.AddCommand(unsetCmd) | ||
| 17 | } | ||
diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go deleted file mode 100644 index e94b83a..0000000 --- a/cmd/ship/env/list.go +++ /dev/null | |||
| @@ -1,72 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var listCmd = &cobra.Command{ | ||
| 12 | Use: "list <app>", | ||
| 13 | Short: "List environment variables for an app", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runList, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runList(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | if err := state.ValidateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | st, err := state.Load() | ||
| 25 | if err != nil { | ||
| 26 | return fmt.Errorf("error loading state: %w", err) | ||
| 27 | } | ||
| 28 | |||
| 29 | host, _ := cmd.Flags().GetString("host") | ||
| 30 | if host == "" { | ||
| 31 | host = st.GetDefaultHost() | ||
| 32 | } | ||
| 33 | |||
| 34 | if host == "" { | ||
| 35 | return fmt.Errorf("--host is required") | ||
| 36 | } | ||
| 37 | |||
| 38 | app, err := st.GetApp(host, name) | ||
| 39 | if err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | |||
| 43 | if app.Type != "app" { | ||
| 44 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 45 | } | ||
| 46 | |||
| 47 | fmt.Printf("Environment variables for %s:\n\n", name) | ||
| 48 | if len(app.Env) == 0 { | ||
| 49 | fmt.Println(" (none)") | ||
| 50 | } else { | ||
| 51 | for k, v := range app.Env { | ||
| 52 | display := v | ||
| 53 | if isSensitive(k) { | ||
| 54 | display = "***" | ||
| 55 | } | ||
| 56 | fmt.Printf(" %s=%s\n", k, display) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | return nil | ||
| 61 | } | ||
| 62 | |||
| 63 | func isSensitive(key string) bool { | ||
| 64 | key = strings.ToLower(key) | ||
| 65 | sensitiveWords := []string{"key", "secret", "password", "token", "api"} | ||
| 66 | for _, word := range sensitiveWords { | ||
| 67 | if strings.Contains(key, word) { | ||
| 68 | return true | ||
| 69 | } | ||
| 70 | } | ||
| 71 | return false | ||
| 72 | } | ||
diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go deleted file mode 100644 index d4292f3..0000000 --- a/cmd/ship/env/set.go +++ /dev/null | |||
| @@ -1,135 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/ship/internal/ssh" | ||
| 10 | "github.com/bdw/ship/internal/state" | ||
| 11 | "github.com/spf13/cobra" | ||
| 12 | ) | ||
| 13 | |||
| 14 | var setCmd = &cobra.Command{ | ||
| 15 | Use: "set <app> KEY=VALUE...", | ||
| 16 | Short: "Set environment variable(s)", | ||
| 17 | Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.", | ||
| 18 | Args: cobra.MinimumNArgs(2), | ||
| 19 | RunE: runSet, | ||
| 20 | } | ||
| 21 | |||
| 22 | func init() { | ||
| 23 | setCmd.Flags().StringP("file", "f", "", "Load environment from file") | ||
| 24 | } | ||
| 25 | |||
| 26 | func runSet(cmd *cobra.Command, args []string) error { | ||
| 27 | name := args[0] | ||
| 28 | if err := state.ValidateName(name); err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 31 | envVars := args[1:] | ||
| 32 | |||
| 33 | st, err := state.Load() | ||
| 34 | if err != nil { | ||
| 35 | return fmt.Errorf("error loading state: %w", err) | ||
| 36 | } | ||
| 37 | |||
| 38 | host, _ := cmd.Flags().GetString("host") | ||
| 39 | if host == "" { | ||
| 40 | host = st.GetDefaultHost() | ||
| 41 | } | ||
| 42 | |||
| 43 | if host == "" { | ||
| 44 | return fmt.Errorf("--host is required") | ||
| 45 | } | ||
| 46 | |||
| 47 | app, err := st.GetApp(host, name) | ||
| 48 | if err != nil { | ||
| 49 | return err | ||
| 50 | } | ||
| 51 | |||
| 52 | if app.Type != "app" { | ||
| 53 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 54 | } | ||
| 55 | |||
| 56 | if app.Env == nil { | ||
| 57 | app.Env = make(map[string]string) | ||
| 58 | } | ||
| 59 | |||
| 60 | // Set variables from args | ||
| 61 | for _, e := range envVars { | ||
| 62 | parts := strings.SplitN(e, "=", 2) | ||
| 63 | if len(parts) == 2 { | ||
| 64 | app.Env[parts[0]] = parts[1] | ||
| 65 | fmt.Printf("Set %s\n", parts[0]) | ||
| 66 | } else { | ||
| 67 | return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | // Set variables from file if provided | ||
| 72 | envFile, _ := cmd.Flags().GetString("file") | ||
| 73 | if envFile != "" { | ||
| 74 | fileEnv, err := parseEnvFile(envFile) | ||
| 75 | if err != nil { | ||
| 76 | return fmt.Errorf("error reading env file: %w", err) | ||
| 77 | } | ||
| 78 | for k, v := range fileEnv { | ||
| 79 | app.Env[k] = v | ||
| 80 | fmt.Printf("Set %s\n", k) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | if err := st.Save(); err != nil { | ||
| 85 | return fmt.Errorf("error saving state: %w", err) | ||
| 86 | } | ||
| 87 | |||
| 88 | client, err := ssh.Connect(host) | ||
| 89 | if err != nil { | ||
| 90 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 91 | } | ||
| 92 | defer client.Close() | ||
| 93 | |||
| 94 | fmt.Println("-> Updating environment file on VPS...") | ||
| 95 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 96 | envContent := "" | ||
| 97 | for k, v := range app.Env { | ||
| 98 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 99 | } | ||
| 100 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 101 | return fmt.Errorf("error updating env file: %w", err) | ||
| 102 | } | ||
| 103 | |||
| 104 | fmt.Println("-> Restarting service...") | ||
| 105 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 106 | return fmt.Errorf("error restarting service: %w", err) | ||
| 107 | } | ||
| 108 | |||
| 109 | fmt.Println("Environment variables updated successfully") | ||
| 110 | return nil | ||
| 111 | } | ||
| 112 | |||
| 113 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 114 | file, err := os.Open(path) | ||
| 115 | if err != nil { | ||
| 116 | return nil, err | ||
| 117 | } | ||
| 118 | defer file.Close() | ||
| 119 | |||
| 120 | env := make(map[string]string) | ||
| 121 | scanner := bufio.NewScanner(file) | ||
| 122 | for scanner.Scan() { | ||
| 123 | line := strings.TrimSpace(scanner.Text()) | ||
| 124 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 125 | continue | ||
| 126 | } | ||
| 127 | |||
| 128 | parts := strings.SplitN(line, "=", 2) | ||
| 129 | if len(parts) == 2 { | ||
| 130 | env[parts[0]] = parts[1] | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | return env, scanner.Err() | ||
| 135 | } | ||
diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go deleted file mode 100644 index 8292f42..0000000 --- a/cmd/ship/env/unset.go +++ /dev/null | |||
| @@ -1,95 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var unsetCmd = &cobra.Command{ | ||
| 12 | Use: "unset <app> KEY...", | ||
| 13 | Short: "Unset environment variable(s)", | ||
| 14 | Long: "Remove one or more environment variables from an app.", | ||
| 15 | Args: cobra.MinimumNArgs(2), | ||
| 16 | RunE: runUnset, | ||
| 17 | } | ||
| 18 | |||
| 19 | func runUnset(cmd *cobra.Command, args []string) error { | ||
| 20 | name := args[0] | ||
| 21 | if err := state.ValidateName(name); err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | keys := args[1:] | ||
| 25 | |||
| 26 | st, err := state.Load() | ||
| 27 | if err != nil { | ||
| 28 | return fmt.Errorf("error loading state: %w", err) | ||
| 29 | } | ||
| 30 | |||
| 31 | host, _ := cmd.Flags().GetString("host") | ||
| 32 | if host == "" { | ||
| 33 | host = st.GetDefaultHost() | ||
| 34 | } | ||
| 35 | |||
| 36 | if host == "" { | ||
| 37 | return fmt.Errorf("--host is required") | ||
| 38 | } | ||
| 39 | |||
| 40 | app, err := st.GetApp(host, name) | ||
| 41 | if err != nil { | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | |||
| 45 | if app.Type != "app" { | ||
| 46 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 47 | } | ||
| 48 | |||
| 49 | if app.Env == nil { | ||
| 50 | return fmt.Errorf("no environment variables set") | ||
| 51 | } | ||
| 52 | |||
| 53 | changed := false | ||
| 54 | for _, key := range keys { | ||
| 55 | if _, exists := app.Env[key]; exists { | ||
| 56 | delete(app.Env, key) | ||
| 57 | changed = true | ||
| 58 | fmt.Printf("Unset %s\n", key) | ||
| 59 | } else { | ||
| 60 | fmt.Printf("Warning: %s not found\n", key) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | if !changed { | ||
| 65 | return nil | ||
| 66 | } | ||
| 67 | |||
| 68 | if err := st.Save(); err != nil { | ||
| 69 | return fmt.Errorf("error saving state: %w", err) | ||
| 70 | } | ||
| 71 | |||
| 72 | client, err := ssh.Connect(host) | ||
| 73 | if err != nil { | ||
| 74 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 75 | } | ||
| 76 | defer client.Close() | ||
| 77 | |||
| 78 | fmt.Println("-> Updating environment file on VPS...") | ||
| 79 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 80 | envContent := "" | ||
| 81 | for k, v := range app.Env { | ||
| 82 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 83 | } | ||
| 84 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 85 | return fmt.Errorf("error updating env file: %w", err) | ||
| 86 | } | ||
| 87 | |||
| 88 | fmt.Println("-> Restarting service...") | ||
| 89 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 90 | return fmt.Errorf("error restarting service: %w", err) | ||
| 91 | } | ||
| 92 | |||
| 93 | fmt.Println("Environment variables updated successfully") | ||
| 94 | return nil | ||
| 95 | } | ||
diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go deleted file mode 100644 index 81403f9..0000000 --- a/cmd/ship/host/host.go +++ /dev/null | |||
| @@ -1,21 +0,0 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/spf13/cobra" | ||
| 5 | ) | ||
| 6 | |||
| 7 | var Cmd = &cobra.Command{ | ||
| 8 | Use: "host", | ||
| 9 | Short: "Manage VPS host", | ||
| 10 | Long: "Commands for managing and monitoring the VPS host", | ||
| 11 | } | ||
| 12 | |||
| 13 | func init() { | ||
| 14 | Cmd.AddCommand(initCmd) | ||
| 15 | Cmd.AddCommand(statusCmd) | ||
| 16 | Cmd.AddCommand(updateCmd) | ||
| 17 | Cmd.AddCommand(sshCmd) | ||
| 18 | Cmd.AddCommand(setDomainCmd) | ||
| 19 | |||
| 20 | initCmd.Flags().String("base-domain", "", "Base domain for auto-generated subdomains (e.g., apps.example.com)") | ||
| 21 | } | ||
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go deleted file mode 100644 index cfa2795..0000000 --- a/cmd/ship/host/init.go +++ /dev/null | |||
| @@ -1,316 +0,0 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/bdw/ship/internal/ssh" | ||
| 8 | "github.com/bdw/ship/internal/state" | ||
| 9 | "github.com/bdw/ship/internal/templates" | ||
| 10 | "github.com/spf13/cobra" | ||
| 11 | ) | ||
| 12 | |||
| 13 | var initCmd = &cobra.Command{ | ||
| 14 | Use: "init", | ||
| 15 | Short: "Initialize VPS (one-time setup)", | ||
| 16 | Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories", | ||
| 17 | RunE: runInit, | ||
| 18 | } | ||
| 19 | |||
| 20 | func runInit(cmd *cobra.Command, args []string) error { | ||
| 21 | st, err := state.Load() | ||
| 22 | if err != nil { | ||
| 23 | return fmt.Errorf("error loading state: %w", err) | ||
| 24 | } | ||
| 25 | |||
| 26 | host, _ := cmd.Flags().GetString("host") | ||
| 27 | if host == "" { | ||
| 28 | host = st.GetDefaultHost() | ||
| 29 | } | ||
| 30 | baseDomain, _ := cmd.Flags().GetString("base-domain") | ||
| 31 | |||
| 32 | if host == "" { | ||
| 33 | return fmt.Errorf("--host is required") | ||
| 34 | } | ||
| 35 | |||
| 36 | fmt.Printf("Initializing VPS: %s\n", host) | ||
| 37 | |||
| 38 | client, err := ssh.Connect(host) | ||
| 39 | if err != nil { | ||
| 40 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 41 | } | ||
| 42 | defer client.Close() | ||
| 43 | |||
| 44 | fmt.Println("-> Detecting OS...") | ||
| 45 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 46 | if err != nil { | ||
| 47 | return fmt.Errorf("error detecting OS: %w", err) | ||
| 48 | } | ||
| 49 | |||
| 50 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 51 | return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)") | ||
| 52 | } | ||
| 53 | fmt.Println(" Detected Ubuntu/Debian") | ||
| 54 | |||
| 55 | fmt.Println("-> Checking for Caddy...") | ||
| 56 | _, err = client.Run("which caddy") | ||
| 57 | if err == nil { | ||
| 58 | fmt.Println(" Caddy already installed") | ||
| 59 | } else { | ||
| 60 | fmt.Println(" Installing Caddy...") | ||
| 61 | if err := installCaddy(client); err != nil { | ||
| 62 | return err | ||
| 63 | } | ||
| 64 | fmt.Println(" Caddy installed") | ||
| 65 | } | ||
| 66 | |||
| 67 | fmt.Println("-> Configuring Caddy...") | ||
| 68 | caddyfile := `{ | ||
| 69 | } | ||
| 70 | |||
| 71 | import /etc/caddy/sites-enabled/* | ||
| 72 | ` | ||
| 73 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 74 | return fmt.Errorf("error creating Caddyfile: %w", err) | ||
| 75 | } | ||
| 76 | fmt.Println(" Caddyfile created") | ||
| 77 | |||
| 78 | fmt.Println("-> Creating directories...") | ||
| 79 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 80 | return fmt.Errorf("error creating /etc/ship/env: %w", err) | ||
| 81 | } | ||
| 82 | if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { | ||
| 83 | return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err) | ||
| 84 | } | ||
| 85 | fmt.Println(" Directories created") | ||
| 86 | |||
| 87 | fmt.Println("-> Starting Caddy...") | ||
| 88 | if _, err := client.RunSudo("systemctl enable caddy"); err != nil { | ||
| 89 | return fmt.Errorf("error enabling Caddy: %w", err) | ||
| 90 | } | ||
| 91 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { | ||
| 92 | return fmt.Errorf("error starting Caddy: %w", err) | ||
| 93 | } | ||
| 94 | fmt.Println(" Caddy started") | ||
| 95 | |||
| 96 | fmt.Println("-> Verifying installation...") | ||
| 97 | output, err := client.RunSudo("systemctl is-active caddy") | ||
| 98 | if err != nil || strings.TrimSpace(output) != "active" { | ||
| 99 | fmt.Println(" Warning: Caddy may not be running properly") | ||
| 100 | } else { | ||
| 101 | fmt.Println(" Caddy is active") | ||
| 102 | } | ||
| 103 | |||
| 104 | hostState := st.GetHost(host) | ||
| 105 | if baseDomain != "" { | ||
| 106 | hostState.BaseDomain = baseDomain | ||
| 107 | fmt.Printf(" Base domain: %s\n", baseDomain) | ||
| 108 | } | ||
| 109 | |||
| 110 | // Git-centric deployment setup (gated on base domain) | ||
| 111 | if baseDomain != "" { | ||
| 112 | if err := setupGitDeploy(client, baseDomain, hostState); err != nil { | ||
| 113 | return err | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | if st.GetDefaultHost() == "" { | ||
| 118 | st.SetDefaultHost(host) | ||
| 119 | fmt.Printf(" Set %s as default host\n", host) | ||
| 120 | } | ||
| 121 | if err := st.Save(); err != nil { | ||
| 122 | return fmt.Errorf("error saving state: %w", err) | ||
| 123 | } | ||
| 124 | |||
| 125 | fmt.Println("\nVPS initialized successfully!") | ||
| 126 | fmt.Println("\nNext steps:") | ||
| 127 | fmt.Println(" 1. Deploy an app:") | ||
| 128 | fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") | ||
| 129 | fmt.Println(" 2. Deploy a static site:") | ||
| 130 | fmt.Printf(" ship --static --dir ./dist --domain example.com\n") | ||
| 131 | if baseDomain != "" { | ||
| 132 | fmt.Println(" 3. Initialize a git-deployed app:") | ||
| 133 | fmt.Printf(" ship init myapp\n") | ||
| 134 | } | ||
| 135 | return nil | ||
| 136 | } | ||
| 137 | |||
| 138 | func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error { | ||
| 139 | fmt.Println("-> Installing Docker...") | ||
| 140 | dockerCommands := []string{ | ||
| 141 | "apt-get install -y ca-certificates curl gnupg", | ||
| 142 | "install -m 0755 -d /etc/apt/keyrings", | ||
| 143 | "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", | ||
| 144 | "chmod a+r /etc/apt/keyrings/docker.asc", | ||
| 145 | `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'`, | ||
| 146 | "apt-get update", | ||
| 147 | "apt-get install -y docker-ce docker-ce-cli containerd.io", | ||
| 148 | } | ||
| 149 | for _, cmd := range dockerCommands { | ||
| 150 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 151 | return fmt.Errorf("error installing Docker: %w", err) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | fmt.Println(" Docker installed") | ||
| 155 | |||
| 156 | fmt.Println("-> Installing git, fcgiwrap, and cgit...") | ||
| 157 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { | ||
| 158 | return fmt.Errorf("error installing git/fcgiwrap/cgit: %w", err) | ||
| 159 | } | ||
| 160 | // Allow git-http-backend (runs as www-data) to access repos owned by git. | ||
| 161 | // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. | ||
| 162 | // www-data's home is /var/www; ensure it can write .gitconfig there. | ||
| 163 | client.RunSudo("mkdir -p /var/www") | ||
| 164 | client.RunSudo("chown www-data:www-data /var/www") | ||
| 165 | if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { | ||
| 166 | return fmt.Errorf("error setting git safe.directory: %w", err) | ||
| 167 | } | ||
| 168 | fmt.Println(" git, fcgiwrap, and cgit installed") | ||
| 169 | |||
| 170 | fmt.Println("-> Creating git user...") | ||
| 171 | // Create git user (ignore error if already exists) | ||
| 172 | client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") | ||
| 173 | if _, err := client.RunSudo("usermod -aG docker git"); err != nil { | ||
| 174 | return fmt.Errorf("error adding git user to docker group: %w", err) | ||
| 175 | } | ||
| 176 | // www-data needs to read git repos for git-http-backend | ||
| 177 | if _, err := client.RunSudo("usermod -aG git www-data"); err != nil { | ||
| 178 | return fmt.Errorf("error adding www-data to git group: %w", err) | ||
| 179 | } | ||
| 180 | // caddy needs to connect to fcgiwrap socket (owned by www-data) | ||
| 181 | if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil { | ||
| 182 | return fmt.Errorf("error adding caddy to www-data group: %w", err) | ||
| 183 | } | ||
| 184 | fmt.Println(" git user created") | ||
| 185 | |||
| 186 | fmt.Println("-> Copying SSH keys to git user...") | ||
| 187 | copyKeysCommands := []string{ | ||
| 188 | "mkdir -p /home/git/.ssh", | ||
| 189 | "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", | ||
| 190 | "chown -R git:git /home/git/.ssh", | ||
| 191 | "chmod 700 /home/git/.ssh", | ||
| 192 | "chmod 600 /home/git/.ssh/authorized_keys", | ||
| 193 | } | ||
| 194 | for _, cmd := range copyKeysCommands { | ||
| 195 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 196 | return fmt.Errorf("error copying SSH keys: %w", err) | ||
| 197 | } | ||
| 198 | } | ||
| 199 | fmt.Println(" SSH keys copied") | ||
| 200 | |||
| 201 | fmt.Println("-> Creating /srv/git...") | ||
| 202 | if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil { | ||
| 203 | return fmt.Errorf("error creating /srv/git: %w", err) | ||
| 204 | } | ||
| 205 | if _, err := client.RunSudo("chown git:git /srv/git"); err != nil { | ||
| 206 | return fmt.Errorf("error setting /srv/git ownership: %w", err) | ||
| 207 | } | ||
| 208 | fmt.Println(" /srv/git created") | ||
| 209 | |||
| 210 | fmt.Println("-> Writing sudoers for git user...") | ||
| 211 | sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services. | ||
| 212 | # App names are validated to [a-z][a-z0-9-] before reaching this point. | ||
| 213 | git ALL=(ALL) NOPASSWD: \ | ||
| 214 | /bin/systemctl daemon-reload, \ | ||
| 215 | /bin/systemctl reload caddy, \ | ||
| 216 | /bin/systemctl restart [a-z]*, \ | ||
| 217 | /bin/systemctl enable [a-z]*, \ | ||
| 218 | /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ | ||
| 219 | /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 220 | /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 221 | /bin/mkdir -p /var/lib/*, \ | ||
| 222 | /bin/mkdir -p /var/www/*, \ | ||
| 223 | /bin/chown -R git\:git /var/lib/*, \ | ||
| 224 | /bin/chown git\:git /var/www/* | ||
| 225 | ` | ||
| 226 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | ||
| 227 | return fmt.Errorf("error writing sudoers: %w", err) | ||
| 228 | } | ||
| 229 | if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil { | ||
| 230 | return fmt.Errorf("error setting sudoers permissions: %w", err) | ||
| 231 | } | ||
| 232 | fmt.Println(" sudoers configured") | ||
| 233 | |||
| 234 | fmt.Println("-> Writing vanity import template...") | ||
| 235 | vanityHTML := `<!DOCTYPE html> | ||
| 236 | <html><head> | ||
| 237 | {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} | ||
| 238 | {{$parts := splitList "/" $path}} | ||
| 239 | {{$module := first $parts}} | ||
| 240 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | ||
| 241 | </head> | ||
| 242 | <body>go get {{.Host}}/{{$module}}</body> | ||
| 243 | </html> | ||
| 244 | ` | ||
| 245 | if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil { | ||
| 246 | return fmt.Errorf("error creating vanity directory: %w", err) | ||
| 247 | } | ||
| 248 | if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil { | ||
| 249 | return fmt.Errorf("error writing vanity template: %w", err) | ||
| 250 | } | ||
| 251 | fmt.Println(" vanity template written") | ||
| 252 | |||
| 253 | fmt.Println("-> Writing base domain Caddy config...") | ||
| 254 | codeCaddyContent, err := templates.CodeCaddy(map[string]string{ | ||
| 255 | "BaseDomain": baseDomain, | ||
| 256 | }) | ||
| 257 | if err != nil { | ||
| 258 | return fmt.Errorf("error generating code caddy config: %w", err) | ||
| 259 | } | ||
| 260 | if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil { | ||
| 261 | return fmt.Errorf("error writing code caddy config: %w", err) | ||
| 262 | } | ||
| 263 | fmt.Println(" base domain Caddy config written") | ||
| 264 | |||
| 265 | fmt.Println("-> Writing cgit config...") | ||
| 266 | cgitrcContent, err := templates.CgitRC(map[string]string{ | ||
| 267 | "BaseDomain": baseDomain, | ||
| 268 | }) | ||
| 269 | if err != nil { | ||
| 270 | return fmt.Errorf("error generating cgitrc: %w", err) | ||
| 271 | } | ||
| 272 | if err := client.WriteSudoFile("/etc/cgitrc", cgitrcContent); err != nil { | ||
| 273 | return fmt.Errorf("error writing cgitrc: %w", err) | ||
| 274 | } | ||
| 275 | if err := client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()); err != nil { | ||
| 276 | return fmt.Errorf("error writing cgit header: %w", err) | ||
| 277 | } | ||
| 278 | fmt.Println(" cgit config written") | ||
| 279 | |||
| 280 | fmt.Println("-> Starting Docker and fcgiwrap...") | ||
| 281 | if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { | ||
| 282 | return fmt.Errorf("error enabling services: %w", err) | ||
| 283 | } | ||
| 284 | if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil { | ||
| 285 | return fmt.Errorf("error starting services: %w", err) | ||
| 286 | } | ||
| 287 | fmt.Println(" Docker and fcgiwrap started") | ||
| 288 | |||
| 289 | fmt.Println("-> Restarting Caddy...") | ||
| 290 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { | ||
| 291 | return fmt.Errorf("error restarting Caddy: %w", err) | ||
| 292 | } | ||
| 293 | fmt.Println(" Caddy restarted") | ||
| 294 | |||
| 295 | hostState.GitSetup = true | ||
| 296 | fmt.Println(" Git deployment setup complete") | ||
| 297 | return nil | ||
| 298 | } | ||
| 299 | |||
| 300 | func installCaddy(client *ssh.Client) error { | ||
| 301 | commands := []string{ | ||
| 302 | "apt-get update", | ||
| 303 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", | ||
| 304 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", | ||
| 305 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", | ||
| 306 | "apt-get update", | ||
| 307 | "apt-get install -y caddy", | ||
| 308 | } | ||
| 309 | |||
| 310 | for _, cmd := range commands { | ||
| 311 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 312 | return fmt.Errorf("error running: %s: %w", cmd, err) | ||
| 313 | } | ||
| 314 | } | ||
| 315 | return nil | ||
| 316 | } | ||
diff --git a/cmd/ship/host/set_domain.go b/cmd/ship/host/set_domain.go deleted file mode 100644 index fed3b31..0000000 --- a/cmd/ship/host/set_domain.go +++ /dev/null | |||
| @@ -1,76 +0,0 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/state" | ||
| 7 | "github.com/spf13/cobra" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var setDomainCmd = &cobra.Command{ | ||
| 11 | Use: "set-domain [domain]", | ||
| 12 | Short: "Set base domain for auto-generated subdomains", | ||
| 13 | Long: `Set the base domain used to auto-generate subdomains for deployments. | ||
| 14 | |||
| 15 | When a base domain is configured (e.g., apps.example.com), every deployment | ||
| 16 | will automatically get a subdomain ({name}.apps.example.com). | ||
| 17 | |||
| 18 | Examples: | ||
| 19 | ship host set-domain apps.example.com # Set base domain | ||
| 20 | ship host set-domain --clear # Remove base domain`, | ||
| 21 | RunE: runSetDomain, | ||
| 22 | } | ||
| 23 | |||
| 24 | func init() { | ||
| 25 | setDomainCmd.Flags().Bool("clear", false, "Clear the base domain") | ||
| 26 | } | ||
| 27 | |||
| 28 | func runSetDomain(cmd *cobra.Command, args []string) error { | ||
| 29 | st, err := state.Load() | ||
| 30 | if err != nil { | ||
| 31 | return fmt.Errorf("error loading state: %w", err) | ||
| 32 | } | ||
| 33 | |||
| 34 | host, _ := cmd.Flags().GetString("host") | ||
| 35 | if host == "" { | ||
| 36 | host = st.GetDefaultHost() | ||
| 37 | } | ||
| 38 | |||
| 39 | if host == "" { | ||
| 40 | return fmt.Errorf("--host is required") | ||
| 41 | } | ||
| 42 | |||
| 43 | clear, _ := cmd.Flags().GetBool("clear") | ||
| 44 | |||
| 45 | if !clear && len(args) == 0 { | ||
| 46 | // Show current base domain | ||
| 47 | hostState := st.GetHost(host) | ||
| 48 | if hostState.BaseDomain == "" { | ||
| 49 | fmt.Printf("No base domain configured for %s\n", host) | ||
| 50 | } else { | ||
| 51 | fmt.Printf("Base domain for %s: %s\n", host, hostState.BaseDomain) | ||
| 52 | } | ||
| 53 | return nil | ||
| 54 | } | ||
| 55 | |||
| 56 | hostState := st.GetHost(host) | ||
| 57 | |||
| 58 | if clear { | ||
| 59 | hostState.BaseDomain = "" | ||
| 60 | if err := st.Save(); err != nil { | ||
| 61 | return fmt.Errorf("error saving state: %w", err) | ||
| 62 | } | ||
| 63 | fmt.Printf("Cleared base domain for %s\n", host) | ||
| 64 | return nil | ||
| 65 | } | ||
| 66 | |||
| 67 | hostState.BaseDomain = args[0] | ||
| 68 | if err := st.Save(); err != nil { | ||
| 69 | return fmt.Errorf("error saving state: %w", err) | ||
| 70 | } | ||
| 71 | |||
| 72 | fmt.Printf("Set base domain for %s: %s\n", host, args[0]) | ||
| 73 | fmt.Println("\nNew deployments will automatically use subdomains like:") | ||
| 74 | fmt.Printf(" myapp.%s\n", args[0]) | ||
| 75 | return nil | ||
| 76 | } | ||
diff --git a/cmd/ship/host/ssh.go b/cmd/ship/host/ssh.go deleted file mode 100644 index e480e47..0000000 --- a/cmd/ship/host/ssh.go +++ /dev/null | |||
| @@ -1,45 +0,0 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | |||
| 8 | "github.com/bdw/ship/internal/state" | ||
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | ||
| 11 | |||
| 12 | var sshCmd = &cobra.Command{ | ||
| 13 | Use: "ssh", | ||
| 14 | Short: "Open interactive SSH session", | ||
| 15 | RunE: runSSH, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runSSH(cmd *cobra.Command, args []string) error { | ||
| 19 | st, err := state.Load() | ||
| 20 | if err != nil { | ||
| 21 | return fmt.Errorf("error loading state: %w", err) | ||
| 22 | } | ||
| 23 | |||
| 24 | host, _ := cmd.Flags().GetString("host") | ||
| 25 | if host == "" { | ||
| 26 | host = st.GetDefaultHost() | ||
| 27 | } | ||
| 28 | |||
| 29 | if host == "" { | ||
| 30 | return fmt.Errorf("--host is required (no default host set)") | ||
| 31 | } | ||
| 32 | |||
| 33 | sshCmd := exec.Command("ssh", host) | ||
| 34 | sshCmd.Stdin = os.Stdin | ||
| 35 | sshCmd.Stdout = os.Stdout | ||
| 36 | sshCmd.Stderr = os.Stderr | ||
| 37 | |||
| 38 | if err := sshCmd.Run(); err != nil { | ||
| 39 | if exitErr, ok := err.(*exec.ExitError); ok { | ||
| 40 | os.Exit(exitErr.ExitCode()) | ||
| 41 | } | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | return nil | ||
| 45 | } | ||
diff --git a/cmd/ship/host/status.go b/cmd/ship/host/status.go deleted file mode 100644 index eb2de53..0000000 --- a/cmd/ship/host/status.go +++ /dev/null | |||
| @@ -1,108 +0,0 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var statusCmd = &cobra.Command{ | ||
| 12 | Use: "status", | ||
| 13 | Short: "Show VPS health (uptime, disk, memory)", | ||
| 14 | RunE: runStatus, | ||
| 15 | } | ||
| 16 | |||
| 17 | func runStatus(cmd *cobra.Command, args []string) error { | ||
| 18 | st, err := state.Load() | ||
| 19 | if err != nil { | ||
| 20 | return fmt.Errorf("error loading state: %w", err) | ||
| 21 | } | ||
| 22 | |||
| 23 | host, _ := cmd.Flags().GetString("host") | ||
| 24 | if host == "" { | ||
| 25 | host = st.GetDefaultHost() | ||
| 26 | } | ||
| 27 | |||
| 28 | if host == "" { | ||
| 29 | return fmt.Errorf("--host is required (no default host set)") | ||
| 30 | } | ||
| 31 | |||
| 32 | fmt.Printf("Connecting to %s...\n\n", host) | ||
| 33 | |||
| 34 | client, err := ssh.Connect(host) | ||
| 35 | if err != nil { | ||
| 36 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 37 | } | ||
| 38 | defer client.Close() | ||
| 39 | |||
| 40 | fmt.Println("UPTIME") | ||
| 41 | if output, err := client.Run("uptime -p"); err == nil { | ||
| 42 | fmt.Printf(" %s", output) | ||
| 43 | } | ||
| 44 | if output, err := client.Run("uptime -s"); err == nil { | ||
| 45 | fmt.Printf(" Since: %s", output) | ||
| 46 | } | ||
| 47 | fmt.Println() | ||
| 48 | |||
| 49 | fmt.Println("LOAD") | ||
| 50 | if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil { | ||
| 51 | fmt.Printf(" 1m, 5m, 15m: %s", output) | ||
| 52 | } | ||
| 53 | fmt.Println() | ||
| 54 | |||
| 55 | fmt.Println("MEMORY") | ||
| 56 | if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil { | ||
| 57 | fmt.Print(output) | ||
| 58 | } | ||
| 59 | if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil { | ||
| 60 | fmt.Print(output) | ||
| 61 | } | ||
| 62 | fmt.Println() | ||
| 63 | |||
| 64 | fmt.Println("DISK") | ||
| 65 | if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil { | ||
| 66 | fmt.Print(output) | ||
| 67 | } | ||
| 68 | if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil { | ||
| 69 | fmt.Print(output) | ||
| 70 | } | ||
| 71 | fmt.Println() | ||
| 72 | |||
| 73 | fmt.Println("UPDATES") | ||
| 74 | if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil { | ||
| 75 | fmt.Print(output) | ||
| 76 | } | ||
| 77 | fmt.Println() | ||
| 78 | |||
| 79 | fmt.Println("SERVICES") | ||
| 80 | if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { | ||
| 81 | if output == "active\n" { | ||
| 82 | fmt.Println(" Caddy: active") | ||
| 83 | } else { | ||
| 84 | fmt.Println(" Caddy: inactive") | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | hostState := st.GetHost(host) | ||
| 89 | if hostState != nil && len(hostState.Apps) > 0 { | ||
| 90 | activeCount := 0 | ||
| 91 | for name, app := range hostState.Apps { | ||
| 92 | if app.Type == "app" { | ||
| 93 | if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" { | ||
| 94 | activeCount++ | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | appCount := 0 | ||
| 99 | for _, app := range hostState.Apps { | ||
| 100 | if app.Type == "app" { | ||
| 101 | appCount++ | ||
| 102 | } | ||
| 103 | } | ||
| 104 | fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount) | ||
| 105 | } | ||
| 106 | |||
| 107 | return nil | ||
| 108 | } | ||
diff --git a/cmd/ship/host/update.go b/cmd/ship/host/update.go deleted file mode 100644 index 5f838b6..0000000 --- a/cmd/ship/host/update.go +++ /dev/null | |||
| @@ -1,93 +0,0 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/ship/internal/ssh" | ||
| 10 | "github.com/bdw/ship/internal/state" | ||
| 11 | "github.com/spf13/cobra" | ||
| 12 | ) | ||
| 13 | |||
| 14 | var updateCmd = &cobra.Command{ | ||
| 15 | Use: "update", | ||
| 16 | Short: "Update VPS packages", | ||
| 17 | Long: "Run apt update && apt upgrade on the VPS", | ||
| 18 | RunE: runUpdate, | ||
| 19 | } | ||
| 20 | |||
| 21 | func init() { | ||
| 22 | updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") | ||
| 23 | } | ||
| 24 | |||
| 25 | func runUpdate(cmd *cobra.Command, args []string) error { | ||
| 26 | st, err := state.Load() | ||
| 27 | if err != nil { | ||
| 28 | return fmt.Errorf("error loading state: %w", err) | ||
| 29 | } | ||
| 30 | |||
| 31 | host, _ := cmd.Flags().GetString("host") | ||
| 32 | if host == "" { | ||
| 33 | host = st.GetDefaultHost() | ||
| 34 | } | ||
| 35 | |||
| 36 | if host == "" { | ||
| 37 | return fmt.Errorf("--host is required (no default host set)") | ||
| 38 | } | ||
| 39 | |||
| 40 | yes, _ := cmd.Flags().GetBool("yes") | ||
| 41 | if !yes { | ||
| 42 | fmt.Printf("This will run apt update && apt upgrade on %s\n", host) | ||
| 43 | fmt.Print("Continue? [Y/n]: ") | ||
| 44 | reader := bufio.NewReader(os.Stdin) | ||
| 45 | response, _ := reader.ReadString('\n') | ||
| 46 | response = strings.TrimSpace(response) | ||
| 47 | if response == "n" || response == "N" { | ||
| 48 | fmt.Println("Aborted.") | ||
| 49 | return nil | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | fmt.Printf("Connecting to %s...\n", host) | ||
| 54 | |||
| 55 | client, err := ssh.Connect(host) | ||
| 56 | if err != nil { | ||
| 57 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 58 | } | ||
| 59 | defer client.Close() | ||
| 60 | |||
| 61 | fmt.Println("\n-> Running apt update...") | ||
| 62 | if err := client.RunSudoStream("apt update"); err != nil { | ||
| 63 | return fmt.Errorf("error running apt update: %w", err) | ||
| 64 | } | ||
| 65 | |||
| 66 | fmt.Println("\n-> Running apt upgrade...") | ||
| 67 | if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { | ||
| 68 | return fmt.Errorf("error running apt upgrade: %w", err) | ||
| 69 | } | ||
| 70 | |||
| 71 | fmt.Println() | ||
| 72 | if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { | ||
| 73 | if strings.TrimSpace(output) == "yes" { | ||
| 74 | fmt.Print("A reboot is required to complete the update. Reboot now? [Y/n]: ") | ||
| 75 | reader := bufio.NewReader(os.Stdin) | ||
| 76 | response, _ := reader.ReadString('\n') | ||
| 77 | response = strings.TrimSpace(response) | ||
| 78 | if response == "" || response == "y" || response == "Y" { | ||
| 79 | fmt.Println("Rebooting...") | ||
| 80 | if _, err := client.RunSudo("reboot"); err != nil { | ||
| 81 | // reboot command often returns an error as connection drops | ||
| 82 | // this is expected behavior | ||
| 83 | } | ||
| 84 | fmt.Println("Reboot initiated. The host will be back online shortly.") | ||
| 85 | return nil | ||
| 86 | } | ||
| 87 | fmt.Println("Skipping reboot. Run 'sudo reboot' manually when ready.") | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | fmt.Println("Update complete") | ||
| 92 | return nil | ||
| 93 | } | ||
diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go new file mode 100644 index 0000000..b19c376 --- /dev/null +++ b/cmd/ship/host_v2.go | |||
| @@ -0,0 +1,445 @@ | |||
| 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/init.go b/cmd/ship/init.go deleted file mode 100644 index b495702..0000000 --- a/cmd/ship/init.go +++ /dev/null | |||
| @@ -1,268 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | "path/filepath" | ||
| 8 | "strconv" | ||
| 9 | |||
| 10 | "github.com/bdw/ship/internal/ssh" | ||
| 11 | "github.com/bdw/ship/internal/state" | ||
| 12 | "github.com/bdw/ship/internal/templates" | ||
| 13 | "github.com/spf13/cobra" | ||
| 14 | ) | ||
| 15 | |||
| 16 | var initCmd = &cobra.Command{ | ||
| 17 | Use: "init <name>", | ||
| 18 | Short: "Initialize a git-deployed project", | ||
| 19 | Long: `Create a bare git repo on the VPS and generate local .ship/ config files. | ||
| 20 | |||
| 21 | Pushing to the remote triggers an automatic docker build and deploy (for apps) | ||
| 22 | or a static file checkout (for static sites). If no Dockerfile is present in an | ||
| 23 | app repo, pushes are accepted without triggering a deploy. | ||
| 24 | |||
| 25 | Examples: | ||
| 26 | # Initialize an app (Docker-based) | ||
| 27 | ship init myapp | ||
| 28 | |||
| 29 | # Initialize with a custom domain | ||
| 30 | ship init myapp --domain custom.example.com | ||
| 31 | |||
| 32 | # Initialize a static site | ||
| 33 | ship init mysite --static | ||
| 34 | |||
| 35 | # Initialize a public repo (cloneable via go get / git clone over HTTPS) | ||
| 36 | ship init mylib --public`, | ||
| 37 | Args: cobra.ExactArgs(1), | ||
| 38 | RunE: runInit, | ||
| 39 | } | ||
| 40 | |||
| 41 | func init() { | ||
| 42 | initCmd.Flags().Bool("static", false, "Initialize as static site") | ||
| 43 | initCmd.Flags().Bool("public", false, "Make repo publicly cloneable over HTTPS (for go get)") | ||
| 44 | initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)") | ||
| 45 | } | ||
| 46 | |||
| 47 | func runInit(cmd *cobra.Command, args []string) error { | ||
| 48 | name := args[0] | ||
| 49 | if err := validateName(name); err != nil { | ||
| 50 | return err | ||
| 51 | } | ||
| 52 | static, _ := cmd.Flags().GetBool("static") | ||
| 53 | public, _ := cmd.Flags().GetBool("public") | ||
| 54 | domain, _ := cmd.Flags().GetString("domain") | ||
| 55 | |||
| 56 | st, err := state.Load() | ||
| 57 | if err != nil { | ||
| 58 | return fmt.Errorf("error loading state: %w", err) | ||
| 59 | } | ||
| 60 | |||
| 61 | host := hostFlag | ||
| 62 | if host == "" { | ||
| 63 | host = st.GetDefaultHost() | ||
| 64 | } | ||
| 65 | if host == "" { | ||
| 66 | return fmt.Errorf("--host is required") | ||
| 67 | } | ||
| 68 | |||
| 69 | hostState := st.GetHost(host) | ||
| 70 | if !hostState.GitSetup { | ||
| 71 | return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host) | ||
| 72 | } | ||
| 73 | |||
| 74 | // Check if app already exists | ||
| 75 | if _, err := st.GetApp(host, name); err == nil { | ||
| 76 | return fmt.Errorf("app %s already exists", name) | ||
| 77 | } | ||
| 78 | |||
| 79 | appType := "git-app" | ||
| 80 | if static { | ||
| 81 | appType = "git-static" | ||
| 82 | } | ||
| 83 | |||
| 84 | // Resolve domain | ||
| 85 | if domain == "" && hostState.BaseDomain != "" { | ||
| 86 | domain = name + "." + hostState.BaseDomain | ||
| 87 | } | ||
| 88 | if domain == "" { | ||
| 89 | return fmt.Errorf("--domain required (or configure base domain)") | ||
| 90 | } | ||
| 91 | |||
| 92 | // Allocate port for apps only | ||
| 93 | port := 0 | ||
| 94 | if !static { | ||
| 95 | port = st.AllocatePort(host) | ||
| 96 | } | ||
| 97 | |||
| 98 | fmt.Printf("Initializing %s: %s\n", appType, name) | ||
| 99 | |||
| 100 | client, err := ssh.Connect(host) | ||
| 101 | if err != nil { | ||
| 102 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 103 | } | ||
| 104 | defer client.Close() | ||
| 105 | |||
| 106 | // Create bare repo | ||
| 107 | fmt.Println("-> Creating bare git repo...") | ||
| 108 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 109 | if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare -b main %s", repo)); err != nil { | ||
| 110 | return fmt.Errorf("error creating bare repo: %w", err) | ||
| 111 | } | ||
| 112 | |||
| 113 | if public { | ||
| 114 | if _, err := client.RunSudo(fmt.Sprintf("sudo -u git touch %s/git-daemon-export-ok", repo)); err != nil { | ||
| 115 | return fmt.Errorf("error setting repo public: %w", err) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | if static { | ||
| 120 | // Create web root | ||
| 121 | fmt.Println("-> Creating web root...") | ||
| 122 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil { | ||
| 123 | return fmt.Errorf("error creating web root: %w", err) | ||
| 124 | } | ||
| 125 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil { | ||
| 126 | return fmt.Errorf("error setting web root ownership: %w", err) | ||
| 127 | } | ||
| 128 | |||
| 129 | // Write post-receive hook | ||
| 130 | fmt.Println("-> Writing post-receive hook...") | ||
| 131 | hookContent, err := templates.PostReceiveHookStatic(map[string]string{ | ||
| 132 | "Name": name, | ||
| 133 | }) | ||
| 134 | if err != nil { | ||
| 135 | return fmt.Errorf("error generating hook: %w", err) | ||
| 136 | } | ||
| 137 | if err := writeHook(client, repo, hookContent); err != nil { | ||
| 138 | return err | ||
| 139 | } | ||
| 140 | } else { | ||
| 141 | // Create env file | ||
| 142 | fmt.Println("-> Creating environment file...") | ||
| 143 | envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port) | ||
| 144 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 145 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 146 | return fmt.Errorf("error creating env file: %w", err) | ||
| 147 | } | ||
| 148 | |||
| 149 | // Write post-receive hook (handles dir creation on first push) | ||
| 150 | fmt.Println("-> Writing post-receive hook...") | ||
| 151 | hookContent, err := templates.PostReceiveHook(map[string]string{ | ||
| 152 | "Name": name, | ||
| 153 | }) | ||
| 154 | if err != nil { | ||
| 155 | return fmt.Errorf("error generating hook: %w", err) | ||
| 156 | } | ||
| 157 | if err := writeHook(client, repo, hookContent); err != nil { | ||
| 158 | return err | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | // Save state | ||
| 163 | st.AddApp(host, name, &state.App{ | ||
| 164 | Type: appType, | ||
| 165 | Domain: domain, | ||
| 166 | Port: port, | ||
| 167 | Repo: repo, | ||
| 168 | Public: public, | ||
| 169 | }) | ||
| 170 | if err := st.Save(); err != nil { | ||
| 171 | return fmt.Errorf("error saving state: %w", err) | ||
| 172 | } | ||
| 173 | |||
| 174 | // Generate local .ship/ files | ||
| 175 | fmt.Println("-> Generating local .ship/ config...") | ||
| 176 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 177 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 178 | } | ||
| 179 | |||
| 180 | if static { | ||
| 181 | caddyContent, err := templates.DefaultStaticCaddy(map[string]string{ | ||
| 182 | "Domain": domain, | ||
| 183 | "Name": name, | ||
| 184 | }) | ||
| 185 | if err != nil { | ||
| 186 | return fmt.Errorf("error generating Caddyfile: %w", err) | ||
| 187 | } | ||
| 188 | if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { | ||
| 189 | return fmt.Errorf("error writing Caddyfile: %w", err) | ||
| 190 | } | ||
| 191 | } else { | ||
| 192 | caddyContent, err := templates.DefaultAppCaddy(map[string]string{ | ||
| 193 | "Domain": domain, | ||
| 194 | "Port": strconv.Itoa(port), | ||
| 195 | }) | ||
| 196 | if err != nil { | ||
| 197 | return fmt.Errorf("error generating Caddyfile: %w", err) | ||
| 198 | } | ||
| 199 | if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { | ||
| 200 | return fmt.Errorf("error writing Caddyfile: %w", err) | ||
| 201 | } | ||
| 202 | |||
| 203 | serviceContent, err := templates.DockerService(map[string]string{ | ||
| 204 | "Name": name, | ||
| 205 | "Port": strconv.Itoa(port), | ||
| 206 | }) | ||
| 207 | if err != nil { | ||
| 208 | return fmt.Errorf("error generating service file: %w", err) | ||
| 209 | } | ||
| 210 | if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil { | ||
| 211 | return fmt.Errorf("error writing service file: %w", err) | ||
| 212 | } | ||
| 213 | } | ||
| 214 | |||
| 215 | // Initialize local git repo if needed | ||
| 216 | if _, err := os.Stat(".git"); os.IsNotExist(err) { | ||
| 217 | fmt.Println("-> Initializing git repo...") | ||
| 218 | gitInit := exec.Command("git", "init") | ||
| 219 | gitInit.Stdout = os.Stdout | ||
| 220 | gitInit.Stderr = os.Stderr | ||
| 221 | if err := gitInit.Run(); err != nil { | ||
| 222 | return fmt.Errorf("error initializing git repo: %w", err) | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | // Add origin remote (replace if it already exists) | ||
| 227 | sshHost := host | ||
| 228 | remoteURL := fmt.Sprintf("git@%s:%s", sshHost, repo) | ||
| 229 | exec.Command("git", "remote", "remove", "origin").Run() // ignore error if not exists | ||
| 230 | addRemote := exec.Command("git", "remote", "add", "origin", remoteURL) | ||
| 231 | if err := addRemote.Run(); err != nil { | ||
| 232 | return fmt.Errorf("error adding git remote: %w", err) | ||
| 233 | } | ||
| 234 | |||
| 235 | fmt.Printf("\nProject initialized: %s\n", name) | ||
| 236 | fmt.Println("\nGenerated:") | ||
| 237 | fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)") | ||
| 238 | if !static { | ||
| 239 | fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)") | ||
| 240 | } | ||
| 241 | fmt.Println("\nNext steps:") | ||
| 242 | if static { | ||
| 243 | fmt.Println(" git add .ship/") | ||
| 244 | } else { | ||
| 245 | fmt.Println(" git add .ship/ Dockerfile") | ||
| 246 | } | ||
| 247 | fmt.Println(" git commit -m \"initial deploy\"") | ||
| 248 | fmt.Println(" git push origin main") | ||
| 249 | if !static { | ||
| 250 | fmt.Println("\n (No Dockerfile? Just push — deploy is skipped until one is added.)") | ||
| 251 | } | ||
| 252 | |||
| 253 | return nil | ||
| 254 | } | ||
| 255 | |||
| 256 | func writeHook(client *ssh.Client, repo, content string) error { | ||
| 257 | hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) | ||
| 258 | if err := client.WriteSudoFile(hookPath, content); err != nil { | ||
| 259 | return fmt.Errorf("error writing hook: %w", err) | ||
| 260 | } | ||
| 261 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { | ||
| 262 | return fmt.Errorf("error making hook executable: %w", err) | ||
| 263 | } | ||
| 264 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { | ||
| 265 | return fmt.Errorf("error setting hook ownership: %w", err) | ||
| 266 | } | ||
| 267 | return nil | ||
| 268 | } | ||
diff --git a/cmd/ship/list.go b/cmd/ship/list.go deleted file mode 100644 index af5baf8..0000000 --- a/cmd/ship/list.go +++ /dev/null | |||
| @@ -1,61 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "text/tabwriter" | ||
| 7 | |||
| 8 | "github.com/bdw/ship/internal/state" | ||
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | ||
| 11 | |||
| 12 | var listCmd = &cobra.Command{ | ||
| 13 | Use: "list", | ||
| 14 | Short: "List all deployed apps and sites", | ||
| 15 | RunE: runList, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runList(cmd *cobra.Command, args []string) error { | ||
| 19 | st, err := state.Load() | ||
| 20 | if err != nil { | ||
| 21 | return fmt.Errorf("error loading state: %w", err) | ||
| 22 | } | ||
| 23 | |||
| 24 | host := hostFlag | ||
| 25 | if host == "" { | ||
| 26 | host = st.GetDefaultHost() | ||
| 27 | } | ||
| 28 | |||
| 29 | if host == "" { | ||
| 30 | return fmt.Errorf("--host is required") | ||
| 31 | } | ||
| 32 | |||
| 33 | apps := st.ListApps(host) | ||
| 34 | if len(apps) == 0 { | ||
| 35 | fmt.Printf("No deployments found for %s\n", host) | ||
| 36 | return nil | ||
| 37 | } | ||
| 38 | |||
| 39 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) | ||
| 40 | fmt.Fprintln(w, "NAME\tTYPE\tVISIBILITY\tDOMAIN\tPORT") | ||
| 41 | for name, app := range apps { | ||
| 42 | port := "" | ||
| 43 | if app.Type == "app" || app.Type == "git-app" { | ||
| 44 | port = fmt.Sprintf(":%d", app.Port) | ||
| 45 | } | ||
| 46 | domain := app.Domain | ||
| 47 | if domain == "" { | ||
| 48 | domain = "-" | ||
| 49 | } | ||
| 50 | visibility := "" | ||
| 51 | if app.Repo != "" { | ||
| 52 | visibility = "private" | ||
| 53 | if app.Public { | ||
| 54 | visibility = "public" | ||
| 55 | } | ||
| 56 | } | ||
| 57 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, app.Type, visibility, domain, port) | ||
| 58 | } | ||
| 59 | w.Flush() | ||
| 60 | return nil | ||
| 61 | } | ||
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go deleted file mode 100644 index 4c58a9c..0000000 --- a/cmd/ship/logs.go +++ /dev/null | |||
| @@ -1,78 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var logsCmd = &cobra.Command{ | ||
| 12 | Use: "logs <app>", | ||
| 13 | Short: "View logs for a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runLogs, | ||
| 16 | } | ||
| 17 | |||
| 18 | func init() { | ||
| 19 | logsCmd.Flags().BoolP("follow", "f", false, "Follow logs") | ||
| 20 | logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") | ||
| 21 | } | ||
| 22 | |||
| 23 | func runLogs(cmd *cobra.Command, args []string) error { | ||
| 24 | name := args[0] | ||
| 25 | if err := validateName(name); err != nil { | ||
| 26 | return err | ||
| 27 | } | ||
| 28 | follow, _ := cmd.Flags().GetBool("follow") | ||
| 29 | lines, _ := cmd.Flags().GetInt("lines") | ||
| 30 | |||
| 31 | st, err := state.Load() | ||
| 32 | if err != nil { | ||
| 33 | return fmt.Errorf("error loading state: %w", err) | ||
| 34 | } | ||
| 35 | |||
| 36 | host := hostFlag | ||
| 37 | if host == "" { | ||
| 38 | host = st.GetDefaultHost() | ||
| 39 | } | ||
| 40 | |||
| 41 | if host == "" { | ||
| 42 | return fmt.Errorf("--host is required") | ||
| 43 | } | ||
| 44 | |||
| 45 | app, err := st.GetApp(host, name) | ||
| 46 | if err != nil { | ||
| 47 | return err | ||
| 48 | } | ||
| 49 | |||
| 50 | if app.Type != "app" && app.Type != "git-app" { | ||
| 51 | return fmt.Errorf("logs are only available for apps, not static sites") | ||
| 52 | } | ||
| 53 | |||
| 54 | client, err := ssh.Connect(host) | ||
| 55 | if err != nil { | ||
| 56 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 57 | } | ||
| 58 | defer client.Close() | ||
| 59 | |||
| 60 | journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines) | ||
| 61 | if follow { | ||
| 62 | journalCmd += " -f" | ||
| 63 | } | ||
| 64 | |||
| 65 | if follow { | ||
| 66 | if err := client.RunStream(journalCmd); err != nil { | ||
| 67 | return fmt.Errorf("error fetching logs: %w", err) | ||
| 68 | } | ||
| 69 | } else { | ||
| 70 | output, err := client.Run(journalCmd) | ||
| 71 | if err != nil { | ||
| 72 | return fmt.Errorf("error fetching logs: %w", err) | ||
| 73 | } | ||
| 74 | fmt.Print(output) | ||
| 75 | } | ||
| 76 | |||
| 77 | return nil | ||
| 78 | } | ||
diff --git a/cmd/ship/main.go b/cmd/ship/main.go index f7d95c1..17516fb 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go | |||
| @@ -1,13 +1,10 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import "os" |
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | ) | ||
| 7 | 4 | ||
| 8 | func main() { | 5 | func main() { |
| 9 | if err := rootCmd.Execute(); err != nil { | 6 | initV2() |
| 10 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | 7 | if err := rootV2Cmd.Execute(); err != nil { |
| 11 | os.Exit(1) | 8 | os.Exit(1) |
| 12 | } | 9 | } |
| 13 | } | 10 | } |
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go deleted file mode 100644 index b55d0c8..0000000 --- a/cmd/ship/remove.go +++ /dev/null | |||
| @@ -1,109 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var removeCmd = &cobra.Command{ | ||
| 12 | Use: "remove <app>", | ||
| 13 | Aliases: []string{"rm"}, | ||
| 14 | Short: "Remove a deployment", | ||
| 15 | Args: cobra.ExactArgs(1), | ||
| 16 | RunE: runRemove, | ||
| 17 | } | ||
| 18 | |||
| 19 | func runRemove(cmd *cobra.Command, args []string) error { | ||
| 20 | name := args[0] | ||
| 21 | if err := validateName(name); err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | |||
| 25 | st, err := state.Load() | ||
| 26 | if err != nil { | ||
| 27 | return fmt.Errorf("error loading state: %w", err) | ||
| 28 | } | ||
| 29 | |||
| 30 | host := hostFlag | ||
| 31 | if host == "" { | ||
| 32 | host = st.GetDefaultHost() | ||
| 33 | } | ||
| 34 | |||
| 35 | if host == "" { | ||
| 36 | return fmt.Errorf("--host is required") | ||
| 37 | } | ||
| 38 | |||
| 39 | app, err := st.GetApp(host, name) | ||
| 40 | if err != nil { | ||
| 41 | return err | ||
| 42 | } | ||
| 43 | |||
| 44 | fmt.Printf("Removing deployment: %s\n", name) | ||
| 45 | |||
| 46 | client, err := ssh.Connect(host) | ||
| 47 | if err != nil { | ||
| 48 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 49 | } | ||
| 50 | defer client.Close() | ||
| 51 | |||
| 52 | switch app.Type { | ||
| 53 | case "app": | ||
| 54 | fmt.Println("-> Stopping service...") | ||
| 55 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 56 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 57 | |||
| 58 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 59 | client.RunSudo("systemctl daemon-reload") | ||
| 60 | |||
| 61 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 62 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 63 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 64 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | ||
| 65 | |||
| 66 | case "git-app": | ||
| 67 | fmt.Println("-> Stopping service...") | ||
| 68 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 69 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 70 | |||
| 71 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 72 | client.RunSudo("systemctl daemon-reload") | ||
| 73 | |||
| 74 | fmt.Println("-> Removing Docker image...") | ||
| 75 | client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name)) | ||
| 76 | |||
| 77 | fmt.Println("-> Removing files...") | ||
| 78 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 79 | client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) | ||
| 80 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 81 | |||
| 82 | case "git-static": | ||
| 83 | fmt.Println("-> Removing files...") | ||
| 84 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 85 | client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) | ||
| 86 | |||
| 87 | default: // "static" | ||
| 88 | fmt.Println("-> Removing files...") | ||
| 89 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 90 | } | ||
| 91 | |||
| 92 | fmt.Println("-> Removing Caddy config...") | ||
| 93 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 94 | |||
| 95 | fmt.Println("-> Reloading Caddy...") | ||
| 96 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 97 | fmt.Printf("Warning: Error reloading Caddy: %v\n", err) | ||
| 98 | } | ||
| 99 | |||
| 100 | if err := st.RemoveApp(host, name); err != nil { | ||
| 101 | return fmt.Errorf("error updating state: %w", err) | ||
| 102 | } | ||
| 103 | if err := st.Save(); err != nil { | ||
| 104 | return fmt.Errorf("error saving state: %w", err) | ||
| 105 | } | ||
| 106 | |||
| 107 | fmt.Println("Deployment removed successfully") | ||
| 108 | return nil | ||
| 109 | } | ||
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go deleted file mode 100644 index c902adb..0000000 --- a/cmd/ship/restart.go +++ /dev/null | |||
| @@ -1,60 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var restartCmd = &cobra.Command{ | ||
| 12 | Use: "restart <app>", | ||
| 13 | Short: "Restart a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runRestart, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runRestart(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | if err := validateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | st, err := state.Load() | ||
| 25 | if err != nil { | ||
| 26 | return fmt.Errorf("error loading state: %w", err) | ||
| 27 | } | ||
| 28 | |||
| 29 | host := hostFlag | ||
| 30 | if host == "" { | ||
| 31 | host = st.GetDefaultHost() | ||
| 32 | } | ||
| 33 | |||
| 34 | if host == "" { | ||
| 35 | return fmt.Errorf("--host is required") | ||
| 36 | } | ||
| 37 | |||
| 38 | app, err := st.GetApp(host, name) | ||
| 39 | if err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | |||
| 43 | if app.Type != "app" && app.Type != "git-app" { | ||
| 44 | return fmt.Errorf("restart is only available for apps, not static sites") | ||
| 45 | } | ||
| 46 | |||
| 47 | client, err := ssh.Connect(host) | ||
| 48 | if err != nil { | ||
| 49 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 50 | } | ||
| 51 | defer client.Close() | ||
| 52 | |||
| 53 | fmt.Printf("Restarting %s...\n", name) | ||
| 54 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 55 | return fmt.Errorf("error restarting service: %w", err) | ||
| 56 | } | ||
| 57 | |||
| 58 | fmt.Println("Service restarted successfully") | ||
| 59 | return nil | ||
| 60 | } | ||
diff --git a/cmd/ship/root.go b/cmd/ship/root.go deleted file mode 100644 index 93280f5..0000000 --- a/cmd/ship/root.go +++ /dev/null | |||
| @@ -1,97 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/bdw/ship/cmd/ship/env" | ||
| 5 | "github.com/bdw/ship/cmd/ship/host" | ||
| 6 | "github.com/spf13/cobra" | ||
| 7 | ) | ||
| 8 | |||
| 9 | var ( | ||
| 10 | // Persistent flags | ||
| 11 | hostFlag string | ||
| 12 | |||
| 13 | // Version info (set via ldflags) | ||
| 14 | version = "dev" | ||
| 15 | commit = "none" | ||
| 16 | date = "unknown" | ||
| 17 | ) | ||
| 18 | |||
| 19 | const banner = ` | ||
| 20 | ~ | ||
| 21 | ___|___ | ||
| 22 | | _ | | ||
| 23 | _|__|_|__|_ | ||
| 24 | | SHIP | Ship apps to your VPS | ||
| 25 | \_________/ with automatic HTTPS | ||
| 26 | ~~~~~~~~~ | ||
| 27 | ` | ||
| 28 | |||
| 29 | var rootCmd = &cobra.Command{ | ||
| 30 | Use: "ship", | ||
| 31 | Short: "Ship apps and static sites to a VPS with automatic HTTPS", | ||
| 32 | Long: banner + ` | ||
| 33 | A CLI tool for deploying applications and static sites to a VPS. | ||
| 34 | |||
| 35 | How it works: | ||
| 36 | Ship uses only SSH to deploy - no agents, containers, or external services. | ||
| 37 | It uploads your binary or static website, creates a systemd service, and configures Caddy | ||
| 38 | for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS | ||
| 39 | with minimal overhead. | ||
| 40 | |||
| 41 | Requirements: | ||
| 42 | • A VPS with SSH access (use 'ship host init' to set up a new server) | ||
| 43 | • An SSH config entry or user@host for your server | ||
| 44 | • A domain pointing to your VPS | ||
| 45 | |||
| 46 | Examples: | ||
| 47 | # Deploy a Go binary | ||
| 48 | ship --binary ./myapp --domain api.example.com | ||
| 49 | |||
| 50 | # Deploy with auto-generated subdomain (requires base domain) | ||
| 51 | ship --binary ./myapp --name myapp | ||
| 52 | |||
| 53 | # Deploy a static site | ||
| 54 | ship --static --dir ./dist --domain example.com | ||
| 55 | |||
| 56 | # Update config without redeploying binary | ||
| 57 | ship --name myapp --memory 512M --cpu 50% | ||
| 58 | ship --name myapp --env DEBUG=true | ||
| 59 | |||
| 60 | # Set up a new VPS with base domain | ||
| 61 | ship host init --host user@vps --base-domain apps.example.com`, | ||
| 62 | RunE: runDeploy, | ||
| 63 | SilenceUsage: true, | ||
| 64 | SilenceErrors: true, | ||
| 65 | } | ||
| 66 | |||
| 67 | func init() { | ||
| 68 | // Persistent flags available to all subcommands | ||
| 69 | rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") | ||
| 70 | |||
| 71 | // Root command (deploy) flags | ||
| 72 | rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") | ||
| 73 | rootCmd.Flags().Bool("static", false, "Deploy as static site") | ||
| 74 | rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") | ||
| 75 | rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)") | ||
| 76 | rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") | ||
| 77 | rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") | ||
| 78 | rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 79 | rootCmd.Flags().String("env-file", "", "Path to .env file") | ||
| 80 | rootCmd.Flags().String("args", "", "Arguments to pass to binary") | ||
| 81 | rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") | ||
| 82 | rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)") | ||
| 83 | rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)") | ||
| 84 | |||
| 85 | // Add subcommands | ||
| 86 | rootCmd.AddCommand(listCmd) | ||
| 87 | rootCmd.AddCommand(logsCmd) | ||
| 88 | rootCmd.AddCommand(statusCmd) | ||
| 89 | rootCmd.AddCommand(restartCmd) | ||
| 90 | rootCmd.AddCommand(removeCmd) | ||
| 91 | rootCmd.AddCommand(initCmd) | ||
| 92 | rootCmd.AddCommand(deployGitCmd) | ||
| 93 | rootCmd.AddCommand(env.Cmd) | ||
| 94 | rootCmd.AddCommand(host.Cmd) | ||
| 95 | rootCmd.AddCommand(uiCmd) | ||
| 96 | rootCmd.AddCommand(versionCmd) | ||
| 97 | } | ||
diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go new file mode 100644 index 0000000..aa81d1e --- /dev/null +++ b/cmd/ship/root_v2.go | |||
| @@ -0,0 +1,98 @@ | |||
| 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 | ||
diff --git a/cmd/ship/status.go b/cmd/ship/status.go deleted file mode 100644 index 4774fad..0000000 --- a/cmd/ship/status.go +++ /dev/null | |||
| @@ -1,63 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var statusCmd = &cobra.Command{ | ||
| 12 | Use: "status <app>", | ||
| 13 | Short: "Check status of a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runStatus, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runStatus(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | if err := validateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | st, err := state.Load() | ||
| 25 | if err != nil { | ||
| 26 | return fmt.Errorf("error loading state: %w", err) | ||
| 27 | } | ||
| 28 | |||
| 29 | host := hostFlag | ||
| 30 | if host == "" { | ||
| 31 | host = st.GetDefaultHost() | ||
| 32 | } | ||
| 33 | |||
| 34 | if host == "" { | ||
| 35 | return fmt.Errorf("--host is required") | ||
| 36 | } | ||
| 37 | |||
| 38 | app, err := st.GetApp(host, name) | ||
| 39 | if err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | |||
| 43 | if app.Type != "app" && app.Type != "git-app" { | ||
| 44 | return fmt.Errorf("status is only available for apps, not static sites") | ||
| 45 | } | ||
| 46 | |||
| 47 | client, err := ssh.Connect(host) | ||
| 48 | if err != nil { | ||
| 49 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 50 | } | ||
| 51 | defer client.Close() | ||
| 52 | |||
| 53 | output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) | ||
| 54 | if err != nil { | ||
| 55 | // systemctl status returns non-zero for non-active services | ||
| 56 | // but we still want to show the output | ||
| 57 | fmt.Print(output) | ||
| 58 | return nil | ||
| 59 | } | ||
| 60 | |||
| 61 | fmt.Print(output) | ||
| 62 | return nil | ||
| 63 | } | ||
diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go deleted file mode 100644 index cfaea08..0000000 --- a/cmd/ship/ui.go +++ /dev/null | |||
| @@ -1,199 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "embed" | ||
| 5 | "encoding/json" | ||
| 6 | "fmt" | ||
| 7 | "html/template" | ||
| 8 | "net/http" | ||
| 9 | "sort" | ||
| 10 | "strconv" | ||
| 11 | |||
| 12 | "github.com/bdw/ship/internal/state" | ||
| 13 | "github.com/bdw/ship/internal/templates" | ||
| 14 | "github.com/spf13/cobra" | ||
| 15 | ) | ||
| 16 | |||
| 17 | //go:embed templates/*.html | ||
| 18 | var templatesFS embed.FS | ||
| 19 | |||
| 20 | var uiCmd = &cobra.Command{ | ||
| 21 | Use: "ui", | ||
| 22 | Short: "Launch web management UI", | ||
| 23 | RunE: runUI, | ||
| 24 | } | ||
| 25 | |||
| 26 | func init() { | ||
| 27 | uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") | ||
| 28 | } | ||
| 29 | |||
| 30 | func runUI(cmd *cobra.Command, args []string) error { | ||
| 31 | port, _ := cmd.Flags().GetString("port") | ||
| 32 | |||
| 33 | tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") | ||
| 34 | if err != nil { | ||
| 35 | return fmt.Errorf("error parsing template: %w", err) | ||
| 36 | } | ||
| 37 | |||
| 38 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 39 | st, err := state.Load() | ||
| 40 | if err != nil { | ||
| 41 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 42 | return | ||
| 43 | } | ||
| 44 | |||
| 45 | type AppData struct { | ||
| 46 | Name string | ||
| 47 | Type string | ||
| 48 | Domain string | ||
| 49 | Port int | ||
| 50 | Env map[string]string | ||
| 51 | Host string | ||
| 52 | } | ||
| 53 | |||
| 54 | type HostData struct { | ||
| 55 | Host string | ||
| 56 | Apps []AppData | ||
| 57 | } | ||
| 58 | |||
| 59 | var hosts []HostData | ||
| 60 | for hostName, host := range st.Hosts { | ||
| 61 | var apps []AppData | ||
| 62 | for appName, app := range host.Apps { | ||
| 63 | apps = append(apps, AppData{ | ||
| 64 | Name: appName, | ||
| 65 | Type: app.Type, | ||
| 66 | Domain: app.Domain, | ||
| 67 | Port: app.Port, | ||
| 68 | Env: app.Env, | ||
| 69 | Host: hostName, | ||
| 70 | }) | ||
| 71 | } | ||
| 72 | |||
| 73 | sort.Slice(apps, func(i, j int) bool { | ||
| 74 | return apps[i].Name < apps[j].Name | ||
| 75 | }) | ||
| 76 | |||
| 77 | hosts = append(hosts, HostData{ | ||
| 78 | Host: hostName, | ||
| 79 | Apps: apps, | ||
| 80 | }) | ||
| 81 | } | ||
| 82 | |||
| 83 | sort.Slice(hosts, func(i, j int) bool { | ||
| 84 | return hosts[i].Host < hosts[j].Host | ||
| 85 | }) | ||
| 86 | |||
| 87 | data := struct { | ||
| 88 | Hosts []HostData | ||
| 89 | }{ | ||
| 90 | Hosts: hosts, | ||
| 91 | } | ||
| 92 | |||
| 93 | if err := tmpl.Execute(w, data); err != nil { | ||
| 94 | http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) | ||
| 95 | return | ||
| 96 | } | ||
| 97 | }) | ||
| 98 | |||
| 99 | http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { | ||
| 100 | st, err := state.Load() | ||
| 101 | if err != nil { | ||
| 102 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 103 | return | ||
| 104 | } | ||
| 105 | |||
| 106 | w.Header().Set("Content-Type", "application/json") | ||
| 107 | json.NewEncoder(w).Encode(st) | ||
| 108 | }) | ||
| 109 | |||
| 110 | http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { | ||
| 111 | host := r.URL.Query().Get("host") | ||
| 112 | appName := r.URL.Query().Get("app") | ||
| 113 | |||
| 114 | if host == "" || appName == "" { | ||
| 115 | http.Error(w, "Missing host or app parameter", http.StatusBadRequest) | ||
| 116 | return | ||
| 117 | } | ||
| 118 | |||
| 119 | st, err := state.Load() | ||
| 120 | if err != nil { | ||
| 121 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 122 | return | ||
| 123 | } | ||
| 124 | |||
| 125 | app, err := st.GetApp(host, appName) | ||
| 126 | if err != nil { | ||
| 127 | http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) | ||
| 128 | return | ||
| 129 | } | ||
| 130 | |||
| 131 | configs := make(map[string]string) | ||
| 132 | |||
| 133 | if app.Env != nil && len(app.Env) > 0 { | ||
| 134 | envContent := "" | ||
| 135 | for k, v := range app.Env { | ||
| 136 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 137 | } | ||
| 138 | configs["env"] = envContent | ||
| 139 | configs["envPath"] = fmt.Sprintf("/etc/ship/env/%s.env", appName) | ||
| 140 | } | ||
| 141 | |||
| 142 | if app.Type == "app" { | ||
| 143 | workDir := fmt.Sprintf("/var/lib/%s", appName) | ||
| 144 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) | ||
| 145 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", appName) | ||
| 146 | |||
| 147 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 148 | "Name": appName, | ||
| 149 | "User": appName, | ||
| 150 | "WorkDir": workDir, | ||
| 151 | "BinaryPath": binaryPath, | ||
| 152 | "Port": strconv.Itoa(app.Port), | ||
| 153 | "EnvFile": envFilePath, | ||
| 154 | "Args": app.Args, | ||
| 155 | }) | ||
| 156 | if err != nil { | ||
| 157 | http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) | ||
| 158 | return | ||
| 159 | } | ||
| 160 | configs["systemd"] = serviceContent | ||
| 161 | configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) | ||
| 162 | |||
| 163 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 164 | "Domain": app.Domain, | ||
| 165 | "Port": strconv.Itoa(app.Port), | ||
| 166 | }) | ||
| 167 | if err != nil { | ||
| 168 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 169 | return | ||
| 170 | } | ||
| 171 | configs["caddy"] = caddyContent | ||
| 172 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 173 | } else if app.Type == "static" { | ||
| 174 | remoteDir := fmt.Sprintf("/var/www/%s", appName) | ||
| 175 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 176 | "Domain": app.Domain, | ||
| 177 | "RootDir": remoteDir, | ||
| 178 | }) | ||
| 179 | if err != nil { | ||
| 180 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 181 | return | ||
| 182 | } | ||
| 183 | configs["caddy"] = caddyContent | ||
| 184 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 185 | } | ||
| 186 | |||
| 187 | w.Header().Set("Content-Type", "application/json") | ||
| 188 | json.NewEncoder(w).Encode(configs) | ||
| 189 | }) | ||
| 190 | |||
| 191 | addr := fmt.Sprintf("localhost:%s", port) | ||
| 192 | fmt.Printf("Starting web UI on http://%s\n", addr) | ||
| 193 | fmt.Printf("Press Ctrl+C to stop\n") | ||
| 194 | |||
| 195 | if err := http.ListenAndServe(addr, nil); err != nil { | ||
| 196 | return fmt.Errorf("error starting server: %w", err) | ||
| 197 | } | ||
| 198 | return nil | ||
| 199 | } | ||
diff --git a/cmd/ship/validate.go b/cmd/ship/validate.go deleted file mode 100644 index 00275af..0000000 --- a/cmd/ship/validate.go +++ /dev/null | |||
| @@ -1,9 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import "github.com/bdw/ship/internal/state" | ||
| 4 | |||
| 5 | // validateName checks that an app/project name is safe for use in shell | ||
| 6 | // commands, file paths, systemd units, and DNS labels. | ||
| 7 | func validateName(name string) error { | ||
| 8 | return state.ValidateName(name) | ||
| 9 | } | ||
diff --git a/cmd/ship/version.go b/cmd/ship/version.go deleted file mode 100644 index 6e4314a..0000000 --- a/cmd/ship/version.go +++ /dev/null | |||
| @@ -1,17 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/spf13/cobra" | ||
| 7 | ) | ||
| 8 | |||
| 9 | var versionCmd = &cobra.Command{ | ||
| 10 | Use: "version", | ||
| 11 | Short: "Show version information", | ||
| 12 | Run: func(cmd *cobra.Command, args []string) { | ||
| 13 | fmt.Printf("ship version %s\n", version) | ||
| 14 | fmt.Printf(" commit: %s\n", commit) | ||
| 15 | fmt.Printf(" built: %s\n", date) | ||
| 16 | }, | ||
| 17 | } | ||
diff --git a/internal/detect/detect.go b/internal/detect/detect.go new file mode 100644 index 0000000..f3efd22 --- /dev/null +++ b/internal/detect/detect.go | |||
| @@ -0,0 +1,105 @@ | |||
| 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 new file mode 100644 index 0000000..a9a1036 --- /dev/null +++ b/internal/output/output.go | |||
| @@ -0,0 +1,226 @@ | |||
| 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/state/state.go b/internal/state/state.go index c9aa21d..9b06179 100644 --- a/internal/state/state.go +++ b/internal/state/state.go | |||
| @@ -8,38 +8,18 @@ import ( | |||
| 8 | "regexp" | 8 | "regexp" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // State represents the entire local deployment state | 11 | // State represents the local ship configuration |
| 12 | type State struct { | 12 | type State struct { |
| 13 | DefaultHost string `json:"default_host,omitempty"` | 13 | DefaultHost string `json:"default_host,omitempty"` |
| 14 | Hosts map[string]*Host `json:"hosts"` | 14 | Hosts map[string]*Host `json:"hosts"` |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | // Host represents deployment state for a single VPS | 17 | // Host represents configuration for a single VPS |
| 18 | type Host struct { | 18 | type Host struct { |
| 19 | NextPort int `json:"next_port"` | 19 | BaseDomain string `json:"base_domain,omitempty"` |
| 20 | BaseDomain string `json:"base_domain,omitempty"` | 20 | GitSetup bool `json:"git_setup,omitempty"` |
| 21 | GitSetup bool `json:"git_setup,omitempty"` | ||
| 22 | Apps map[string]*App `json:"apps"` | ||
| 23 | } | 21 | } |
| 24 | 22 | ||
| 25 | // App represents a deployed application or static site | ||
| 26 | type App struct { | ||
| 27 | Type string `json:"type"` // "app", "static", "git-app", or "git-static" | ||
| 28 | Domain string `json:"domain"` | ||
| 29 | Port int `json:"port,omitempty"` // only for type="app" or "git-app" | ||
| 30 | Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git" | ||
| 31 | Public bool `json:"public,omitempty"` // only for git types, enables HTTP clone access | ||
| 32 | Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app" | ||
| 33 | Args string `json:"args,omitempty"` // only for type="app" | ||
| 34 | Files []string `json:"files,omitempty"` // only for type="app" | ||
| 35 | Memory string `json:"memory,omitempty"` // only for type="app" | ||
| 36 | CPU string `json:"cpu,omitempty"` // only for type="app" | ||
| 37 | } | ||
| 38 | |||
| 39 | const ( | ||
| 40 | startPort = 8001 | ||
| 41 | ) | ||
| 42 | |||
| 43 | var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) | 23 | var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) |
| 44 | 24 | ||
| 45 | // ValidateName checks that a name is safe for use in shell commands, | 25 | // ValidateName checks that a name is safe for use in shell commands, |
| @@ -55,7 +35,6 @@ func ValidateName(name string) error { | |||
| 55 | func Load() (*State, error) { | 35 | func Load() (*State, error) { |
| 56 | path := statePath() | 36 | path := statePath() |
| 57 | 37 | ||
| 58 | // If file doesn't exist, return empty state | ||
| 59 | if _, err := os.Stat(path); os.IsNotExist(err) { | 38 | if _, err := os.Stat(path); os.IsNotExist(err) { |
| 60 | return &State{ | 39 | return &State{ |
| 61 | Hosts: make(map[string]*Host), | 40 | Hosts: make(map[string]*Host), |
| @@ -72,7 +51,6 @@ func Load() (*State, error) { | |||
| 72 | return nil, fmt.Errorf("failed to parse state file: %w", err) | 51 | return nil, fmt.Errorf("failed to parse state file: %w", err) |
| 73 | } | 52 | } |
| 74 | 53 | ||
| 75 | // Initialize maps if nil | ||
| 76 | if state.Hosts == nil { | 54 | if state.Hosts == nil { |
| 77 | state.Hosts = make(map[string]*Host) | 55 | state.Hosts = make(map[string]*Host) |
| 78 | } | 56 | } |
| @@ -84,7 +62,6 @@ func Load() (*State, error) { | |||
| 84 | func (s *State) Save() error { | 62 | func (s *State) Save() error { |
| 85 | path := statePath() | 63 | path := statePath() |
| 86 | 64 | ||
| 87 | // Ensure directory exists | ||
| 88 | dir := filepath.Dir(path) | 65 | dir := filepath.Dir(path) |
| 89 | if err := os.MkdirAll(dir, 0755); err != nil { | 66 | if err := os.MkdirAll(dir, 0755); err != nil { |
| 90 | return fmt.Errorf("failed to create config directory: %w", err) | 67 | return fmt.Errorf("failed to create config directory: %w", err) |
| @@ -102,60 +79,14 @@ func (s *State) Save() error { | |||
| 102 | return nil | 79 | return nil |
| 103 | } | 80 | } |
| 104 | 81 | ||
| 105 | // GetHost returns the host state, creating it if it doesn't exist | 82 | // GetHost returns the host config, creating it if it doesn't exist |
| 106 | func (s *State) GetHost(host string) *Host { | 83 | func (s *State) GetHost(host string) *Host { |
| 107 | if s.Hosts[host] == nil { | 84 | if s.Hosts[host] == nil { |
| 108 | s.Hosts[host] = &Host{ | 85 | s.Hosts[host] = &Host{} |
| 109 | NextPort: startPort, | ||
| 110 | Apps: make(map[string]*App), | ||
| 111 | } | ||
| 112 | } | ||
| 113 | if s.Hosts[host].Apps == nil { | ||
| 114 | s.Hosts[host].Apps = make(map[string]*App) | ||
| 115 | } | 86 | } |
| 116 | return s.Hosts[host] | 87 | return s.Hosts[host] |
| 117 | } | 88 | } |
| 118 | 89 | ||
| 119 | // AllocatePort returns the next available port for a host | ||
| 120 | func (s *State) AllocatePort(host string) int { | ||
| 121 | h := s.GetHost(host) | ||
| 122 | port := h.NextPort | ||
| 123 | h.NextPort++ | ||
| 124 | return port | ||
| 125 | } | ||
| 126 | |||
| 127 | // AddApp adds or updates an app in the state | ||
| 128 | func (s *State) AddApp(host, name string, app *App) { | ||
| 129 | h := s.GetHost(host) | ||
| 130 | h.Apps[name] = app | ||
| 131 | } | ||
| 132 | |||
| 133 | // RemoveApp removes an app from the state | ||
| 134 | func (s *State) RemoveApp(host, name string) error { | ||
| 135 | h := s.GetHost(host) | ||
| 136 | if _, exists := h.Apps[name]; !exists { | ||
| 137 | return fmt.Errorf("app %s not found", name) | ||
| 138 | } | ||
| 139 | delete(h.Apps, name) | ||
| 140 | return nil | ||
| 141 | } | ||
| 142 | |||
| 143 | // GetApp returns an app from the state | ||
| 144 | func (s *State) GetApp(host, name string) (*App, error) { | ||
| 145 | h := s.GetHost(host) | ||
| 146 | app, exists := h.Apps[name] | ||
| 147 | if !exists { | ||
| 148 | return nil, fmt.Errorf("app %s not found", name) | ||
| 149 | } | ||
| 150 | return app, nil | ||
| 151 | } | ||
| 152 | |||
| 153 | // ListApps returns all apps for a host | ||
| 154 | func (s *State) ListApps(host string) map[string]*App { | ||
| 155 | h := s.GetHost(host) | ||
| 156 | return h.Apps | ||
| 157 | } | ||
| 158 | |||
| 159 | // GetDefaultHost returns the default host, or empty string if not set | 90 | // GetDefaultHost returns the default host, or empty string if not set |
| 160 | func (s *State) GetDefaultHost() string { | 91 | func (s *State) GetDefaultHost() string { |
| 161 | return s.DefaultHost | 92 | return s.DefaultHost |
| @@ -166,11 +97,9 @@ func (s *State) SetDefaultHost(host string) { | |||
| 166 | s.DefaultHost = host | 97 | s.DefaultHost = host |
| 167 | } | 98 | } |
| 168 | 99 | ||
| 169 | // statePath returns the path to the state file | ||
| 170 | func statePath() string { | 100 | func statePath() string { |
| 171 | home, err := os.UserHomeDir() | 101 | home, err := os.UserHomeDir() |
| 172 | if err != nil { | 102 | if err != nil { |
| 173 | // Fallback to current directory (should rarely happen) | ||
| 174 | return ".ship-state.json" | 103 | return ".ship-state.json" |
| 175 | } | 104 | } |
| 176 | return filepath.Join(home, ".config", "ship", "state.json") | 105 | return filepath.Join(home, ".config", "ship", "state.json") |
diff --git a/internal/templates/templates.go b/internal/templates/templates.go index b68a504..2163f47 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go | |||
| @@ -216,7 +216,7 @@ Requires=docker.service | |||
| 216 | Type=simple | 216 | Type=simple |
| 217 | ExecStartPre=-/usr/bin/docker rm -f {{.Name}} | 217 | ExecStartPre=-/usr/bin/docker rm -f {{.Name}} |
| 218 | ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ | 218 | ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ |
| 219 | -p 127.0.0.1:{{.Port}}:{{.Port}} \ | 219 | -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \ |
| 220 | --env-file /etc/ship/env/{{.Name}}.env \ | 220 | --env-file /etc/ship/env/{{.Name}}.env \ |
| 221 | -v /var/lib/{{.Name}}/data:/data \ | 221 | -v /var/lib/{{.Name}}/data:/data \ |
| 222 | {{.Name}}:latest | 222 | {{.Name}}:latest |
diff --git a/ship-new b/ship-new new file mode 100755 index 0000000..28b9780 --- /dev/null +++ b/ship-new | |||
| Binary files differ | |||
diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..e7e6d7a --- /dev/null +++ b/website/index.html | |||
| @@ -0,0 +1,216 @@ | |||
| 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> | ||
