From 805541df0f22f1200692ddfae7b2f199a89a8f7b Mon Sep 17 00:00:00 2001 From: Clawd Date: Tue, 17 Feb 2026 08:13:28 -0800 Subject: Remove remaining planning docs (SHIP_V2.md, SPEC.md) --- SHIP_V2.md | 240 ----------------------------- SPEC.md | 499 ------------------------------------------------------------- 2 files changed, 739 deletions(-) delete mode 100644 SHIP_V2.md delete mode 100644 SPEC.md diff --git a/SHIP_V2.md b/SHIP_V2.md deleted file mode 100644 index 7b607cb..0000000 --- a/SHIP_V2.md +++ /dev/null @@ -1,240 +0,0 @@ -# Ship v2 - -**Ship is a deployment tool built for AI agents.** - -Agents write code. Ship puts it on the internet. That's it. - -## The Problem - -AI agents can write code, but deploying it is a mess: -- Vercel/Railway/Fly require accounts, API tokens, platform-specific config -- Docker/K8s are overkill for "make this code accessible via URL" -- Most tools assume a human is reading the output - -Agents need: code → URL. Nothing else. - -## Ship's Approach - -```bash -ship ./myproject -# {"status":"ok","url":"https://abc123.example.com","took_ms":4200} -``` - -**That's the entire interface.** Point ship at code, get a URL back as JSON. - -### Core Principles - -1. **JSON in, JSON out** — No human-formatted output. No spinners. No emoji. Agents parse JSON. - -2. **One command** — No workflows. No "init then configure then deploy." One command does everything. - -3. **Verify or fail** — Every deploy is health-checked. If the app isn't responding, ship returns an error. No silent failures. - -4. **Self-cleaning** — Deploys can auto-expire. Agents create lots of previews; they shouldn't pile up forever. - -5. **Zero config** — Point at a directory. Ship figures out if it's static, Docker, or a binary. No config files required. - -6. **SSH-only** — No accounts. No API tokens. No vendor lock-in. Just SSH access to a VPS. - -## Interface - -### Deploy - -```bash -ship ./myproject -ship ./myproject --name myapp -ship ./myproject --name preview --ttl 24h -ship ./site --static -ship ./api --health /healthz -``` - -Output: -```json -{ - "status": "ok", - "name": "myapp", - "url": "https://myapp.example.com", - "type": "docker", - "took_ms": 4200, - "health": {"status": 200, "latency_ms": 45} -} -``` - -### Error - -```json -{ - "status": "error", - "code": "HEALTH_CHECK_FAILED", - "message": "GET /healthz returned 503 after 30s", - "name": "myapp", - "url": "https://myapp.example.com" -} -``` - -### List - -```bash -ship list -``` - -```json -{ - "status": "ok", - "deploys": [ - {"name": "api", "url": "https://api.example.com", "type": "docker", "running": true}, - {"name": "preview-x7k", "url": "https://preview-x7k.example.com", "type": "static", "expires": "2024-02-16T18:00:00Z"} - ] -} -``` - -### Status / Logs / Remove - -```bash -ship status myapp -ship logs myapp -ship logs myapp --lines 100 -ship remove myapp -``` - -All return JSON with `{"status": "ok", ...}` or `{"status": "error", "code": "...", ...}`. - -## Error Codes - -Machine-readable. No guessing. - -| Code | Meaning | -|------|---------| -| `SSH_FAILED` | Can't connect to VPS | -| `UPLOAD_FAILED` | File transfer failed | -| `BUILD_FAILED` | Docker build or compile failed | -| `DEPLOY_FAILED` | systemd/Caddy setup failed | -| `HEALTH_CHECK_FAILED` | App not responding | -| `NOT_FOUND` | App doesn't exist | -| `CONFLICT` | Name already taken | - -## Auto-Detection - -Ship looks at the directory and figures out what to do: - -| Directory contains | Deploy type | -|-------------------|-------------| -| `Dockerfile` | Docker build → systemd service | -| `index.html` | Static site → Caddy file_server | -| Single executable | Binary → systemd service | -| `go.mod` | Go build → systemd service | -| `package.json` + no Dockerfile | Error: "Add a Dockerfile" | - -No config files. No `ship.json`. No `ship init`. - -## TTL (Time-To-Live) - -Agents create previews. Previews should auto-delete. - -```bash -ship ./site --name pr-123 --ttl 1h -ship ./site --name pr-123 --ttl 7d -``` - -After TTL expires, the deploy is removed automatically. The `expires` field in JSON tells you when. - -## Health Checks - -Every deploy is verified. Ship waits for the app to respond before returning success. - -- Static sites: `GET /` returns 2xx -- Apps: `GET /` by default, or specify `--health /healthz` -- Timeout: 30s -- If health check fails: `{"status": "error", "code": "HEALTH_CHECK_FAILED", ...}` - -## Name Generation - -No name? Ship generates one. - -```bash -ship ./site -# {"name": "ship-a1b2c3", "url": "https://ship-a1b2c3.example.com", ...} -``` - -Provide a name to get a stable URL: - -```bash -ship ./site --name docs -# {"name": "docs", "url": "https://docs.example.com", ...} -``` - -## Host Setup - -One-time setup for a VPS: - -```bash -ship host init user@my-vps.com --domain example.com -``` - -```json -{ - "status": "ok", - "host": "my-vps.com", - "domain": "example.com", - "installed": ["caddy", "docker", "systemd"] -} -``` - -After this, the host is ready. Ship remembers it. - -## Human Output - -Humans are an afterthought, but they can use ship too: - -```bash -ship ./site --pretty -``` - -``` -✓ Deployed to https://ship-a1b2c3.example.com (4.2s) -``` - -Or set globally: -```bash -export SHIP_PRETTY=1 -``` - -## Implementation Phases - -### Phase 1: JSON Everything -- [ ] JSON output on all commands -- [ ] Structured error codes -- [ ] Exit codes match error states - -### Phase 2: Smart Deploys -- [ ] Auto-detect project type -- [ ] Health checks on every deploy -- [ ] `--ttl` with server-side cleanup - -### Phase 3: Zero Friction -- [ ] `ship ./dir` with no flags (auto name, auto detect) -- [ ] `ship host init` fully automated -- [ ] One binary, zero dependencies on client - -## Non-Goals - -- **Pretty output** — That's what `--pretty` is for -- **Interactive prompts** — Never. Agents can't answer prompts. -- **Config files** — Zero config. Detect everything. -- **Plugin system** — Keep it simple. -- **Multi-cloud orchestration** — One VPS at a time. - -## Success Criteria - -Ship is done when an agent can: - -1. Build code -2. Run `ship ./code` -3. Parse the JSON response -4. Use the URL - -No docs. No setup. No tokens. No accounts. Just `ship ./code`. - ---- - -*Built for agents. Tolerated by humans.* diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 50b54c2..0000000 --- a/SPEC.md +++ /dev/null @@ -1,499 +0,0 @@ -# Ship v2 Technical Specification - -## Overview - -Ship deploys code to a VPS. Input: path to code. Output: JSON with URL. - -```bash -ship ./myproject -# {"status":"ok","name":"ship-a1b2c3","url":"https://ship-a1b2c3.example.com","type":"docker","took_ms":8200} -``` - -## CLI Interface - -### Primary Command - -``` -ship [PATH] [FLAGS] -``` - -**PATH**: File or directory to deploy. Defaults to `.` (current directory). - -**FLAGS**: -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--name` | string | auto-generated | Deploy name (becomes subdomain) | -| `--host` | string | default host | VPS host (SSH config name or user@host) | -| `--health` | string | `/` for static, none for apps | Health check endpoint | -| `--ttl` | duration | none | Auto-delete after duration (e.g., `1h`, `7d`) | -| `--env` | string[] | none | Environment variables (`KEY=VALUE`) | -| `--env-file` | string | none | Path to .env file | -| `--pretty` | bool | false | Human-readable output | - -### Other Commands - -``` -ship list [--host HOST] -ship status NAME [--host HOST] -ship logs NAME [--lines N] [--host HOST] -ship remove NAME [--host HOST] -ship host init USER@HOST --domain DOMAIN -ship host status [--host HOST] -``` - -All commands output JSON unless `--pretty` is set. - -## Output Schema - -### Success Response - -```typescript -{ - status: "ok", - name: string, - url: string, - type: "static" | "docker" | "binary", - took_ms: number, - health?: { - endpoint: string, - status: number, - latency_ms: number - }, - expires?: string // ISO 8601, only if --ttl set -} -``` - -### Error Response - -```typescript -{ - status: "error", - code: string, - message: string, - name?: string, - url?: string -} -``` - -### Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | Success | -| 1 | Deploy failed | -| 2 | Invalid arguments | -| 3 | SSH connection failed | -| 4 | Health check failed | - -## Error Codes - -| Code | Description | -|------|-------------| -| `INVALID_PATH` | Path doesn't exist or isn't readable | -| `UNKNOWN_PROJECT_TYPE` | Can't detect how to deploy | -| `SSH_CONNECT_FAILED` | Can't establish SSH connection | -| `SSH_AUTH_FAILED` | SSH key rejected | -| `UPLOAD_FAILED` | SCP/rsync failed | -| `BUILD_FAILED` | Docker build or compilation failed | -| `SERVICE_FAILED` | systemd unit failed to start | -| `CADDY_FAILED` | Caddy reload failed | -| `HEALTH_CHECK_FAILED` | App didn't respond in time | -| `HEALTH_CHECK_TIMEOUT` | Health check timed out | -| `NOT_FOUND` | Named deploy doesn't exist | -| `CONFLICT` | Name already in use (if --no-update) | -| `HOST_NOT_CONFIGURED` | No default host, --host required | -| `INVALID_TTL` | Can't parse TTL duration | - -## Auto-Detection Logic - -When given a path, ship determines deploy type: - -``` -is_file(path)? - → is_executable(path)? - → BINARY - → ERROR: "Not an executable file" - -is_directory(path)? - → has_file("Dockerfile")? - → DOCKER - → has_file("index.html") OR has_file("index.htm")? - → STATIC - → has_file("go.mod") AND NOT has_file("Dockerfile")? - → ERROR: "Go project without Dockerfile. Add a Dockerfile or build a binary." - → has_file("package.json") AND NOT has_file("Dockerfile")? - → ERROR: "Node project without Dockerfile. Add a Dockerfile." - → is_empty(path)? - → ERROR: "Directory is empty" - → ERROR: "Can't detect project type. Add a Dockerfile or index.html." -``` - -## Name Generation - -If `--name` not provided: - -``` -name = "ship-" + random_alphanumeric(6) -``` - -Names must match: `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$` - -Invalid names return `INVALID_NAME` error. - -## Port Allocation - -Ports are allocated server-side. - -### Server Files - -``` -/etc/ship/ports/ # Contains allocated port number -``` - -### Allocation Algorithm - -```python -def allocate_port(name): - port_file = f"/etc/ship/ports/{name}" - - if exists(port_file): - return int(read(port_file)) - - used_ports = [int(read(f)) for f in glob("/etc/ship/ports/*")] - next_port = 9000 - while next_port in used_ports: - next_port += 1 - - write(port_file, str(next_port)) - return next_port -``` - -### Port Range - -- Start: 9000 -- Max: 9999 -- Error if exhausted: `PORT_EXHAUSTED` - -## Server File Layout - -``` -/etc/ship/ - ports/ # Port allocation (contains port number) - env/.env # Environment variables - ttl/ # TTL expiry timestamp (Unix epoch) - -/var/www// # Static site files - -/var/lib// - src/ # Docker build context (checked out source) - data/ # Persistent data volume - -/etc/systemd/system/.service # systemd unit -/etc/caddy/sites-enabled/.caddy # Caddy config -``` - -## Deploy Flows - -### Static Site - -1. Validate path is directory with index.html -2. Generate name if needed -3. SSH: Create `/var/www//` -4. rsync: Upload directory contents to `/var/www//` -5. SSH: Set ownership to `www-data` -6. Generate Caddyfile -7. SSH: Write to `/etc/caddy/sites-enabled/.caddy` -8. SSH: Reload Caddy -9. Health check: `GET https://./` -10. If TTL: Write expiry to `/etc/ship/ttl/` -11. Return JSON - -### Docker - -1. Validate path is directory with Dockerfile -2. Generate name if needed -3. Allocate port (server-side) -4. rsync: Upload directory to `/var/lib//src/` -5. SSH: `docker build -t /var/lib//src/` -6. Generate systemd unit (runs `docker run`) -7. SSH: Write to `/etc/systemd/system/.service` -8. SSH: Write env file to `/etc/ship/env/.env` -9. SSH: `systemctl daemon-reload && systemctl restart ` -10. Generate Caddyfile (reverse proxy to port) -11. SSH: Write to `/etc/caddy/sites-enabled/.caddy` -12. SSH: Reload Caddy -13. Health check: `GET https://.` -14. If TTL: Write expiry to `/etc/ship/ttl/` -15. Return JSON - -### Binary - -1. Validate path is executable file -2. Generate name if needed -3. Allocate port (server-side) -4. SCP: Upload binary to `/usr/local/bin/` -5. SSH: `chmod +x /usr/local/bin/` -6. Generate systemd unit -7. SSH: Write to `/etc/systemd/system/.service` -8. SSH: Write env file to `/etc/ship/env/.env` -9. SSH: `systemctl daemon-reload && systemctl restart ` -10. Generate Caddyfile (reverse proxy to port) -11. SSH: Write to `/etc/caddy/sites-enabled/.caddy` -12. SSH: Reload Caddy -13. Health check: `GET https://.` -14. If TTL: Write expiry to `/etc/ship/ttl/` -15. Return JSON - -## Health Checks - -### Behavior - -After deploy, verify the app is responding: - -1. Wait 2 seconds (let app start) -2. `GET https://.` -3. If 2xx or 3xx: success -4. If error or timeout: retry after 2s -5. Max retries: 15 (total 30s) -6. If all retries fail: return `HEALTH_CHECK_FAILED` - -### Health Endpoint - -| Deploy Type | Default | Override | -|-------------|---------|----------| -| static | `/` | `--health` | -| docker | none (skip) | `--health` | -| binary | none (skip) | `--health` | - -When no health endpoint: skip health check, return success after service starts. - -## TTL (Auto-Expiry) - -### Setting TTL - -```bash -ship ./site --name preview --ttl 24h -``` - -Supported formats: `30m`, `1h`, `24h`, `7d` - -### Server-Side Cleanup - -A systemd timer runs hourly: - -```ini -# /etc/systemd/system/ship-cleanup.timer -[Timer] -OnCalendar=hourly -Persistent=true - -# /etc/systemd/system/ship-cleanup.service -[Service] -Type=oneshot -ExecStart=/usr/local/bin/ship-cleanup -``` - -Cleanup script: - -```bash -#!/bin/bash -now=$(date +%s) -for f in /etc/ship/ttl/*; do - name=$(basename "$f") - expires=$(cat "$f") - if [ "$now" -gt "$expires" ]; then - ship remove "$name" - fi -done -``` - -### TTL File Format - -``` -/etc/ship/ttl/ -``` - -Contents: Unix timestamp (seconds since epoch) - -## Templates - -### systemd Unit (Docker) - -```ini -[Unit] -Description=ship: {{.Name}} -After=docker.service -Requires=docker.service - -[Service] -Restart=always -RestartSec=5 -EnvironmentFile=/etc/ship/env/{{.Name}}.env -ExecStartPre=-/usr/bin/docker stop {{.Name}} -ExecStartPre=-/usr/bin/docker rm {{.Name}} -ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ - --env-file /etc/ship/env/{{.Name}}.env \ - -p 127.0.0.1:{{.Port}}:{{.Port}} \ - -v /var/lib/{{.Name}}/data:/data \ - {{.Name}} -ExecStop=/usr/bin/docker stop {{.Name}} - -[Install] -WantedBy=multi-user.target -``` - -### systemd Unit (Binary) - -```ini -[Unit] -Description=ship: {{.Name}} -After=network.target - -[Service] -Type=simple -Restart=always -RestartSec=5 -User={{.Name}} -EnvironmentFile=/etc/ship/env/{{.Name}}.env -ExecStart=/usr/local/bin/{{.Name}} -WorkingDirectory=/var/lib/{{.Name}} - -[Install] -WantedBy=multi-user.target -``` - -### Caddyfile (Reverse Proxy) - -``` -{{.Domain}} { - reverse_proxy localhost:{{.Port}} -} -``` - -### Caddyfile (Static) - -``` -{{.Domain}} { - root * /var/www/{{.Name}} - file_server - encode gzip -} -``` - -## Environment Variables - -Always set by ship: - -| Variable | Value | -|----------|-------| -| `PORT` | Allocated port number | -| `SHIP_NAME` | Deploy name | -| `SHIP_URL` | Full URL | - -User variables from `--env` or `--env-file` are merged. - -## Host Initialization - -```bash -ship host init user@vps.example.com --domain example.com -``` - -### Steps - -1. SSH: Check connectivity -2. SSH: Install packages (`apt install -y caddy docker.io`) -3. SSH: Enable services (`systemctl enable --now caddy docker`) -4. SSH: Create directories (`/etc/ship/ports`, `/etc/ship/env`, `/etc/ship/ttl`) -5. SSH: Install cleanup timer -6. SSH: Configure Caddy base -7. Update local state with host info - -### Output - -```json -{ - "status": "ok", - "host": "vps.example.com", - "domain": "example.com", - "installed": ["caddy", "docker"] -} -``` - -## Local State - -Stored at `~/.config/ship/state.json`: - -```json -{ - "default_host": "vps", - "hosts": { - "vps": { - "ssh": "user@vps.example.com", - "domain": "example.com" - } - } -} -``` - -Local state is minimal — server is source of truth for ports, deploys, TTLs. - -## Pretty Output - -When `--pretty` is set (or `SHIP_PRETTY=1`): - -### Deploy Success - -``` -✓ Deployed to https://myapp.example.com (4.2s) -``` - -### Deploy Failure - -``` -✗ Deploy failed: health check timed out after 30s -``` - -### List - -``` -NAME URL TYPE STATUS -api https://api.example.com docker running -preview-x7k https://preview-x7k.example.com static running (expires in 23h) -``` - -## Implementation Notes - -### SSH Execution - -Use a single SSH connection with multiplexing: - -```bash -ssh -o ControlMaster=auto -o ControlPath=/tmp/ship-%r@%h:%p -o ControlPersist=60 ... -``` - -### Concurrency - -Ship is not designed for concurrent deploys of the same name. Behavior is undefined. - -Different names can deploy concurrently. - -### Rollback (Future) - -Not in v2.0. Future consideration: keep last N versions, `ship rollback `. - ---- - -## Appendix: Example Session - -```bash -$ mkdir -p /tmp/hello && echo '

Hello

' > /tmp/hello/index.html - -$ ship /tmp/hello --name hello -{"status":"ok","name":"hello","url":"https://hello.example.com","type":"static","took_ms":3200,"health":{"endpoint":"/","status":200,"latency_ms":45}} - -$ ship list -{"status":"ok","deploys":[{"name":"hello","url":"https://hello.example.com","type":"static","running":true}]} - -$ ship remove hello -{"status":"ok","name":"hello","removed":true} -``` -- cgit v1.2.3