diff options
| author | Clawd <ai@clawd.bot> | 2026-04-11 20:43:41 -0700 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-04-11 20:43:41 -0700 |
| commit | d0ae31c24c3c98ae89eebd67227c0c0d01606ed5 (patch) | |
| tree | c684469e0f7d3b65477cfc631ecdaafa3c6a218a | |
| parent | 5548b36e0953c17dbe30f6b63c892b7c83196b20 (diff) | |
Add ship-* Claude skills and plan
Introduces a skills/ directory with 8 Claude skills that reimagine ship
as a set of composable, human-driven deployment tools backed by Claude's
reasoning rather than a rigid CLI.
Skills:
- ship-setup: one-time VPS config, saves host to ~/.config/ship/config.json
- ship-status: derives live state from server, no local state file
- ship-env: read/write env vars with merge semantics, never overwrites
- ship-binary: deploy Go binaries with SQLite backup, correct restart behavior
- ship-caddy: manage per-app Caddyfile with validate-before-reload
- ship-service: systemd management and log inspection
- ship-static: rsync static sites with SPA routing support
- ship-deploy: orchestration runbook tying the others together
Also adds SKILLS_PLAN.md documenting the architecture and rationale.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | SKILLS_PLAN.md | 135 | ||||
| -rw-r--r-- | skills/ship-binary/SKILL.md | 168 | ||||
| -rw-r--r-- | skills/ship-caddy/SKILL.md | 135 | ||||
| -rw-r--r-- | skills/ship-deploy/SKILL.md | 126 | ||||
| -rw-r--r-- | skills/ship-env/SKILL.md | 84 | ||||
| -rw-r--r-- | skills/ship-service/SKILL.md | 133 | ||||
| -rw-r--r-- | skills/ship-setup/SKILL.md | 122 | ||||
| -rw-r--r-- | skills/ship-static/SKILL.md | 121 | ||||
| -rw-r--r-- | skills/ship-status/SKILL.md | 85 |
9 files changed, 1109 insertions, 0 deletions
diff --git a/SKILLS_PLAN.md b/SKILLS_PLAN.md new file mode 100644 index 0000000..ded2b38 --- /dev/null +++ b/SKILLS_PLAN.md | |||
| @@ -0,0 +1,135 @@ | |||
| 1 | # Ship Skills — Reimagining Ship as Claude Skills | ||
| 2 | |||
| 3 | ## The Idea | ||
| 4 | |||
| 5 | Rather than a monolithic CLI that bakes in rigid assumptions, ship becomes a family of | ||
| 6 | narrow, composable Claude skills. Each skill knows how to do one thing well. Claude | ||
| 7 | provides the reasoning and orchestration. The server is the source of truth. | ||
| 8 | |||
| 9 | Skills are completely generic — no hostnames, app names, or passwords baked in. The | ||
| 10 | same skills work for anyone. Share them with a friend, point them at a different VPS, | ||
| 11 | they just work. | ||
| 12 | |||
| 13 | ## Shared Configuration | ||
| 14 | |||
| 15 | A single static file at `~/.config/ship/config.json` holds the VPS host (and little | ||
| 16 | else). All skills read from this file. No vault dependency — works for anyone. | ||
| 17 | |||
| 18 | ```json | ||
| 19 | { | ||
| 20 | "host": "ubuntu@1.2.3.4", | ||
| 21 | "domain": "example.com" | ||
| 22 | } | ||
| 23 | ``` | ||
| 24 | |||
| 25 | The server itself is the source of truth for everything else — what services are | ||
| 26 | running, what ports are allocated, what Caddy configs exist. No local state file that | ||
| 27 | can go stale. | ||
| 28 | |||
| 29 | ## The Skills | ||
| 30 | |||
| 31 | ### `ship-setup` | ||
| 32 | One-time setup. Asks for VPS host if not configured, saves to `~/.config/ship/config.json`, | ||
| 33 | SSHes in and installs server dependencies (Caddy, directory structure, etc). | ||
| 34 | All other skills depend on this having been run once. | ||
| 35 | |||
| 36 | ### `ship-status` | ||
| 37 | Derives current state entirely from the server at runtime: | ||
| 38 | - Running apps → `systemctl list-units --type=service` | ||
| 39 | - Ports → `/etc/ship/ports/` or env files | ||
| 40 | - Domains → parse Caddy configs in `sites-enabled/` | ||
| 41 | - Static sites → list `/var/www/` | ||
| 42 | |||
| 43 | No state file needed. Always accurate. Replaces the need for any local tracking. | ||
| 44 | |||
| 45 | ### `ship-env` | ||
| 46 | Read and write env vars with merge semantics. Never overwrites — reads existing file | ||
| 47 | first, merges new values on top, writes result. Old vars survive redeployments. | ||
| 48 | |||
| 49 | ### `ship-caddy` | ||
| 50 | Manage per-app Caddyfile config. Knows Caddy syntax. Diffs before writing. Validates | ||
| 51 | before reloading. Never regenerates from scratch — only touches what needs changing. | ||
| 52 | |||
| 53 | ### `ship-service` | ||
| 54 | Systemd management. Handles the difference between a new service (enable + start) and | ||
| 55 | an existing one (restart). Status, logs, restart, stop — all covered. | ||
| 56 | |||
| 57 | ### `ship-binary` | ||
| 58 | Upload and install a pre-built binary. SCP to `/tmp`, move to `/usr/local/bin/`, | ||
| 59 | chmod +x, set up work directory and service user. Calls `ship-service` and `ship-env` | ||
| 60 | to complete the deployment. | ||
| 61 | |||
| 62 | ### `ship-static` | ||
| 63 | Rsync a local dist folder to `/var/www/{name}` on the server. Calls `ship-caddy` to | ||
| 64 | configure serving. | ||
| 65 | |||
| 66 | ### `ship-deploy` | ||
| 67 | A runbook skill that orchestrates the others in the right order for a full deployment. | ||
| 68 | Not imperative code — just a checklist of steps with enough context for Claude to | ||
| 69 | reason about what to do. Adapts based on what the user tells it (binary vs static, | ||
| 70 | what env vars are needed, etc). | ||
| 71 | |||
| 72 | ## What the Server Knows | ||
| 73 | |||
| 74 | All persistent state lives on the server in conventional locations: | ||
| 75 | |||
| 76 | ``` | ||
| 77 | /etc/caddy/sites-enabled/{name}.caddy # per-app Caddy config | ||
| 78 | /etc/ship/env/{name}.env # environment variables | ||
| 79 | /etc/ship/ports/{name} # allocated port number | ||
| 80 | /etc/systemd/system/{name}.service # systemd unit | ||
| 81 | /var/www/{name}/ # static site files | ||
| 82 | /var/lib/{name}/ # app work directory (binary, data) | ||
| 83 | /usr/local/bin/{name} # binary executable | ||
| 84 | ``` | ||
| 85 | |||
| 86 | ## Why This Is Better Than the CLI | ||
| 87 | |||
| 88 | - **Transparent** — Claude tells you what it's about to do before doing it | ||
| 89 | - **Flexible** — no rigid assumptions, Claude reasons about edge cases | ||
| 90 | - **Mergeable** — env files, Caddy configs never blindly overwritten | ||
| 91 | - **Debuggable** — if something goes wrong, just ask Claude to fix it | ||
| 92 | - **Shareable** — no app-specific knowledge baked in, works for anyone | ||
| 93 | - **No stale state** — server is always the source of truth | ||
| 94 | |||
| 95 | ## Per-App Notes (Optional) | ||
| 96 | |||
| 97 | The server can't know things like "this app needs FOODTRACKER_PASSWORD on redeploy" | ||
| 98 | or "this app has SQLite at /var/lib/foodtracker/data/". That's documentation, not | ||
| 99 | state. Users can keep these as plain notes in whatever system they prefer — a vault, | ||
| 100 | a README, a comment in a script. The skills don't depend on it. | ||
| 101 | |||
| 102 | ## SQLite Backup | ||
| 103 | |||
| 104 | Before swapping a binary, `ship-binary` checks `/var/lib/{name}/` for any `.db` files | ||
| 105 | and backs them up to `/var/lib/{name}/backups/{timestamp}.db` before proceeding. Silent | ||
| 106 | and automatic — you never lose data from a bad deploy. | ||
| 107 | |||
| 108 | ## Multi-Host Support | ||
| 109 | |||
| 110 | Config supports multiple named hosts. One is marked as default. All skills use the | ||
| 111 | default unless told otherwise. | ||
| 112 | |||
| 113 | ```json | ||
| 114 | { | ||
| 115 | "default": "prod", | ||
| 116 | "hosts": { | ||
| 117 | "prod": { | ||
| 118 | "host": "ubuntu@1.2.3.4", | ||
| 119 | "domain": "example.com" | ||
| 120 | }, | ||
| 121 | "staging": { | ||
| 122 | "host": "ubuntu@5.6.7.8", | ||
| 123 | "domain": "staging.example.com" | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | ``` | ||
| 128 | |||
| 129 | Usage is natural — "deploy foodtracker to staging" and Claude picks the right host. | ||
| 130 | `ship-setup` can be run multiple times to add new hosts. The default can be changed | ||
| 131 | at any time. | ||
| 132 | |||
| 133 | ## Out of Scope (For Now) | ||
| 134 | |||
| 135 | - Health checks — skipping initially, can add later if needed | ||
diff --git a/skills/ship-binary/SKILL.md b/skills/ship-binary/SKILL.md new file mode 100644 index 0000000..16056ff --- /dev/null +++ b/skills/ship-binary/SKILL.md | |||
| @@ -0,0 +1,168 @@ | |||
| 1 | --- | ||
| 2 | name: ship-binary | ||
| 3 | description: Upload and deploy a pre-built binary to a ship VPS. Handles port allocation, systemd service, env vars, Caddy config, and SQLite backup. Use when deploying a Go binary or other compiled executable. | ||
| 4 | argument-hint: "<path-to-binary> <app-name> [host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-binary | ||
| 8 | |||
| 9 | Upload a pre-built binary and deploy it as a systemd service with Caddy reverse proxy. | ||
| 10 | |||
| 11 | ## Read Config | ||
| 12 | |||
| 13 | ```bash | ||
| 14 | python3 -c " | ||
| 15 | import json, os | ||
| 16 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 17 | nick = '<nickname-or-default>' | ||
| 18 | h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']]) | ||
| 19 | print(h['host']) | ||
| 20 | print(h['domain']) | ||
| 21 | " | ||
| 22 | ``` | ||
| 23 | |||
| 24 | ## Inputs | ||
| 25 | |||
| 26 | - **Binary path** — local path to the compiled binary | ||
| 27 | - **App name** — short lowercase name, becomes the service name and subdomain (e.g. `foodtracker`) | ||
| 28 | - **Domain** — defaults to `<app-name>.<base-domain>` from config, ask if different | ||
| 29 | - **Env vars** — ask if there are any env vars to set (beyond PORT/SHIP_NAME/SHIP_URL) | ||
| 30 | - **Host** — use default unless specified | ||
| 31 | |||
| 32 | ## Steps | ||
| 33 | |||
| 34 | ### 1. Check if app already exists | ||
| 35 | |||
| 36 | ```bash | ||
| 37 | ssh <host> "test -f /etc/ship/ports/<app-name> && echo exists || echo new" | ||
| 38 | ``` | ||
| 39 | |||
| 40 | This determines whether to allocate a new port or reuse the existing one. | ||
| 41 | |||
| 42 | ### 2. Backup SQLite databases (if app exists) | ||
| 43 | |||
| 44 | Before touching anything, check for SQLite files in the app's data directory: | ||
| 45 | |||
| 46 | ```bash | ||
| 47 | ssh <host> "find /var/lib/<app-name>/ -name '*.db' 2>/dev/null" | ||
| 48 | ``` | ||
| 49 | |||
| 50 | If any `.db` files are found, back them up: | ||
| 51 | |||
| 52 | ```bash | ||
| 53 | ssh <host> "sudo mkdir -p /var/lib/<app-name>/backups && sudo cp /var/lib/<app-name>/data/<name>.db /var/lib/<app-name>/backups/<name>-\$(date +%Y%m%d-%H%M%S).db" | ||
| 54 | ``` | ||
| 55 | |||
| 56 | Tell the user what was backed up before proceeding. | ||
| 57 | |||
| 58 | ### 3. Allocate or retrieve port | ||
| 59 | |||
| 60 | **New app** — find the highest port in use and add 1: | ||
| 61 | ```bash | ||
| 62 | ssh <host> "sudo bash -c 'max=9000; for f in /etc/ship/ports/*; do p=\$(cat \$f 2>/dev/null); [ \"\$p\" -gt \"\$max\" ] && max=\$p; done; port=\$((max+1)); echo \$port | tee /etc/ship/ports/<app-name>; echo \$port'" | ||
| 63 | ``` | ||
| 64 | |||
| 65 | **Existing app** — reuse the existing port: | ||
| 66 | ```bash | ||
| 67 | ssh <host> "cat /etc/ship/ports/<app-name>" | ||
| 68 | ``` | ||
| 69 | |||
| 70 | ### 4. Upload binary | ||
| 71 | |||
| 72 | ```bash | ||
| 73 | scp <binary-path> <host>:/tmp/<app-name> | ||
| 74 | ssh <host> "sudo mv /tmp/<app-name> /usr/local/bin/<app-name> && sudo chmod +x /usr/local/bin/<app-name>" | ||
| 75 | ``` | ||
| 76 | |||
| 77 | ### 5. Create work directory and service user | ||
| 78 | |||
| 79 | ```bash | ||
| 80 | ssh <host> "sudo mkdir -p /var/lib/<app-name>/data && sudo useradd -r -s /bin/false <app-name> 2>/dev/null || true && sudo chown -R <app-name>:<app-name> /var/lib/<app-name>" | ||
| 81 | ``` | ||
| 82 | |||
| 83 | ### 6. Write env file | ||
| 84 | |||
| 85 | Build the env file content merging ship-managed vars with any user-provided vars. | ||
| 86 | If the file already exists, read it first and merge (new values win, old values survive): | ||
| 87 | |||
| 88 | ```bash | ||
| 89 | ssh <host> "sudo cat /etc/ship/env/<app-name>.env 2>/dev/null" | ||
| 90 | ``` | ||
| 91 | |||
| 92 | Then write merged result: | ||
| 93 | ```bash | ||
| 94 | ssh <host> "sudo tee /etc/ship/env/<app-name>.env > /dev/null << 'EOF' | ||
| 95 | PORT=<port> | ||
| 96 | SHIP_NAME=<app-name> | ||
| 97 | SHIP_URL=https://<domain> | ||
| 98 | <user-env-vars> | ||
| 99 | EOF | ||
| 100 | sudo chmod 600 /etc/ship/env/<app-name>.env" | ||
| 101 | ``` | ||
| 102 | |||
| 103 | ### 7. Write systemd unit | ||
| 104 | |||
| 105 | ```bash | ||
| 106 | ssh <host> "sudo tee /etc/systemd/system/<app-name>.service > /dev/null << 'EOF' | ||
| 107 | [Unit] | ||
| 108 | Description=<app-name> | ||
| 109 | After=network.target | ||
| 110 | |||
| 111 | [Service] | ||
| 112 | Type=simple | ||
| 113 | User=<app-name> | ||
| 114 | WorkingDirectory=/var/lib/<app-name> | ||
| 115 | EnvironmentFile=/etc/ship/env/<app-name>.env | ||
| 116 | ExecStart=/usr/local/bin/<app-name> | ||
| 117 | Restart=always | ||
| 118 | RestartSec=5s | ||
| 119 | NoNewPrivileges=true | ||
| 120 | PrivateTmp=true | ||
| 121 | |||
| 122 | [Install] | ||
| 123 | WantedBy=multi-user.target | ||
| 124 | EOF" | ||
| 125 | ``` | ||
| 126 | |||
| 127 | ### 8. Start or restart service | ||
| 128 | |||
| 129 | ```bash | ||
| 130 | ssh <host> "sudo systemctl daemon-reload" | ||
| 131 | ``` | ||
| 132 | |||
| 133 | **New app:** | ||
| 134 | ```bash | ||
| 135 | ssh <host> "sudo systemctl enable --now <app-name>" | ||
| 136 | ``` | ||
| 137 | |||
| 138 | **Existing app:** | ||
| 139 | ```bash | ||
| 140 | ssh <host> "sudo systemctl restart <app-name>" | ||
| 141 | ``` | ||
| 142 | |||
| 143 | ### 9. Write Caddy config | ||
| 144 | |||
| 145 | ```bash | ||
| 146 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 147 | <domain> { | ||
| 148 | reverse_proxy 127.0.0.1:<port> | ||
| 149 | } | ||
| 150 | EOF | ||
| 151 | sudo systemctl reload caddy" | ||
| 152 | ``` | ||
| 153 | |||
| 154 | ### 10. Confirm | ||
| 155 | |||
| 156 | Tell the user: | ||
| 157 | - App name, URL, and port | ||
| 158 | - Whether it was a new deploy or update | ||
| 159 | - Any SQLite backups made | ||
| 160 | - Any env vars set | ||
| 161 | |||
| 162 | ## Notes | ||
| 163 | |||
| 164 | - Always back up SQLite before swapping the binary | ||
| 165 | - Always merge env vars — never replace the whole file | ||
| 166 | - Use `systemctl restart` for existing apps, `enable --now` for new ones | ||
| 167 | - The data directory `/var/lib/<app-name>/data/` persists across deploys | ||
| 168 | - If the user doesn't specify env vars, ask if they need any before deploying | ||
diff --git a/skills/ship-caddy/SKILL.md b/skills/ship-caddy/SKILL.md new file mode 100644 index 0000000..df79e39 --- /dev/null +++ b/skills/ship-caddy/SKILL.md | |||
| @@ -0,0 +1,135 @@ | |||
| 1 | --- | ||
| 2 | name: ship-caddy | ||
| 3 | description: Manage Caddy configuration for a deployed app. Add, update, or remove site configs. Use when you need to change how Caddy serves an app — custom domains, redirects, auth, headers, etc. | ||
| 4 | argument-hint: "<app-name> [host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-caddy | ||
| 8 | |||
| 9 | Manage per-app Caddy configuration on a ship VPS. | ||
| 10 | |||
| 11 | ## Read Config | ||
| 12 | |||
| 13 | ```bash | ||
| 14 | python3 -c " | ||
| 15 | import json, os | ||
| 16 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 17 | nick = '<nickname-or-default>' | ||
| 18 | h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']]) | ||
| 19 | print(h['host']) | ||
| 20 | " | ||
| 21 | ``` | ||
| 22 | |||
| 23 | ## Usage Patterns | ||
| 24 | |||
| 25 | ### View current Caddy config for an app | ||
| 26 | |||
| 27 | ```bash | ||
| 28 | ssh <host> "sudo cat /etc/caddy/sites-enabled/<app-name>.caddy" | ||
| 29 | ``` | ||
| 30 | |||
| 31 | ### Add or update a site config | ||
| 32 | |||
| 33 | Read the current config first if it exists, then write the new one. Always reload | ||
| 34 | Caddy after writing — validate first by checking syntax: | ||
| 35 | |||
| 36 | ```bash | ||
| 37 | ssh <host> "sudo caddy validate --config /etc/caddy/Caddyfile 2>&1" | ||
| 38 | ``` | ||
| 39 | |||
| 40 | If validation passes: | ||
| 41 | ```bash | ||
| 42 | ssh <host> "sudo systemctl reload caddy" | ||
| 43 | ``` | ||
| 44 | |||
| 45 | If validation fails, show the error and do NOT reload. Tell the user what the | ||
| 46 | problem is. | ||
| 47 | |||
| 48 | ### Standard reverse proxy config (most apps) | ||
| 49 | |||
| 50 | ```bash | ||
| 51 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 52 | <domain> { | ||
| 53 | reverse_proxy 127.0.0.1:<port> | ||
| 54 | } | ||
| 55 | EOF" | ||
| 56 | ``` | ||
| 57 | |||
| 58 | ### Custom domain (in addition to default) | ||
| 59 | |||
| 60 | ```bash | ||
| 61 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 62 | <custom-domain>, <default-domain> { | ||
| 63 | reverse_proxy 127.0.0.1:<port> | ||
| 64 | } | ||
| 65 | EOF" | ||
| 66 | ``` | ||
| 67 | |||
| 68 | ### Basic auth | ||
| 69 | |||
| 70 | ```bash | ||
| 71 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 72 | <domain> { | ||
| 73 | basicauth { | ||
| 74 | <username> <bcrypt-hash> | ||
| 75 | } | ||
| 76 | reverse_proxy 127.0.0.1:<port> | ||
| 77 | } | ||
| 78 | EOF" | ||
| 79 | ``` | ||
| 80 | |||
| 81 | To generate a bcrypt hash for a password: | ||
| 82 | ```bash | ||
| 83 | ssh <host> "caddy hash-password --plaintext '<password>'" | ||
| 84 | ``` | ||
| 85 | |||
| 86 | ### Redirect www to non-www | ||
| 87 | |||
| 88 | ```bash | ||
| 89 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 90 | www.<domain> { | ||
| 91 | redir https://<domain>{uri} permanent | ||
| 92 | } | ||
| 93 | |||
| 94 | <domain> { | ||
| 95 | reverse_proxy 127.0.0.1:<port> | ||
| 96 | } | ||
| 97 | EOF" | ||
| 98 | ``` | ||
| 99 | |||
| 100 | ### Static site | ||
| 101 | |||
| 102 | ```bash | ||
| 103 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 104 | <domain> { | ||
| 105 | root * /var/www/<app-name> | ||
| 106 | file_server | ||
| 107 | encode gzip | ||
| 108 | } | ||
| 109 | EOF" | ||
| 110 | ``` | ||
| 111 | |||
| 112 | ### Remove a site config | ||
| 113 | |||
| 114 | ```bash | ||
| 115 | ssh <host> "sudo rm /etc/caddy/sites-enabled/<app-name>.caddy && sudo systemctl reload caddy" | ||
| 116 | ``` | ||
| 117 | |||
| 118 | Confirm with the user before removing. | ||
| 119 | |||
| 120 | ### View Caddy status and logs | ||
| 121 | |||
| 122 | ```bash | ||
| 123 | ssh <host> "sudo systemctl status caddy --no-pager" | ||
| 124 | ssh <host> "sudo journalctl -u caddy -n 50 --no-pager" | ||
| 125 | ``` | ||
| 126 | |||
| 127 | ## Notes | ||
| 128 | |||
| 129 | - Always validate before reloading — never reload with a broken config | ||
| 130 | - The port for an app can be found at `/etc/ship/ports/<app-name>` | ||
| 131 | - Caddy handles HTTPS automatically — no need to configure certificates | ||
| 132 | - If the user asks for something not covered here, write the appropriate Caddy | ||
| 133 | directives — Caddy's config language is flexible and well documented | ||
| 134 | - Main Caddyfile is at `/etc/caddy/Caddyfile` and imports all files in | ||
| 135 | `/etc/caddy/sites-enabled/` | ||
diff --git a/skills/ship-deploy/SKILL.md b/skills/ship-deploy/SKILL.md new file mode 100644 index 0000000..60cc263 --- /dev/null +++ b/skills/ship-deploy/SKILL.md | |||
| @@ -0,0 +1,126 @@ | |||
| 1 | --- | ||
| 2 | name: ship-deploy | ||
| 3 | description: Deploy an app to a ship VPS. Orchestrates ship-binary or ship-static depending on what you're deploying. Use when you want to deploy or redeploy an app and want Claude to guide the process. | ||
| 4 | argument-hint: "<app-name> [host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-deploy | ||
| 8 | |||
| 9 | Orchestration runbook for deploying apps to a ship VPS. Guides the full deployment | ||
| 10 | process by calling the appropriate ship skills in the right order. | ||
| 11 | |||
| 12 | ## Prerequisites | ||
| 13 | |||
| 14 | - `/ship-setup` must have been run at least once | ||
| 15 | - `~/.config/ship/config.json` must exist and contain at least one host | ||
| 16 | - The app must already be built locally (binary compiled or dist folder ready) | ||
| 17 | |||
| 18 | ## Read Config | ||
| 19 | |||
| 20 | ```bash | ||
| 21 | python3 -c " | ||
| 22 | import json, os | ||
| 23 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 24 | nick = '<nickname-or-default>' | ||
| 25 | h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']]) | ||
| 26 | print(h['host']) | ||
| 27 | print(h['domain']) | ||
| 28 | " | ||
| 29 | ``` | ||
| 30 | |||
| 31 | ## Step 1 — Understand what we're deploying | ||
| 32 | |||
| 33 | Ask the user (or infer from context): | ||
| 34 | |||
| 35 | - **What is the app name?** (e.g. `foodtracker`) | ||
| 36 | - **What type?** Binary or static site? | ||
| 37 | - **Where is the artifact?** Path to binary or dist folder | ||
| 38 | - **What host?** Default unless specified | ||
| 39 | - **Any env vars needed?** Especially for first-time deploys | ||
| 40 | - **Custom domain?** Or use `<app-name>.<base-domain>` | ||
| 41 | |||
| 42 | If any of these are unclear, ask before proceeding. | ||
| 43 | |||
| 44 | ## Step 2 — Check if app already exists | ||
| 45 | |||
| 46 | ```bash | ||
| 47 | ssh <host> "test -f /etc/ship/ports/<app-name> && echo exists || echo new" | ||
| 48 | ``` | ||
| 49 | |||
| 50 | Tell the user whether this is a fresh deploy or an update to an existing app. | ||
| 51 | |||
| 52 | ## Step 3 — Deploy | ||
| 53 | |||
| 54 | ### For a binary app → follow ship-binary | ||
| 55 | |||
| 56 | Key steps in order: | ||
| 57 | 1. Backup any SQLite databases in `/var/lib/<app-name>/` | ||
| 58 | 2. Allocate or retrieve port | ||
| 59 | 3. Upload binary via scp | ||
| 60 | 4. Write/merge env file | ||
| 61 | 5. Write systemd unit | ||
| 62 | 6. `systemctl restart` (existing) or `systemctl enable --now` (new) | ||
| 63 | 7. Write Caddy config and reload | ||
| 64 | |||
| 65 | ### For a static site → follow ship-static | ||
| 66 | |||
| 67 | Key steps in order: | ||
| 68 | 1. Validate `index.html` exists in dist folder | ||
| 69 | 2. Rsync dist folder to `/var/www/<app-name>/` | ||
| 70 | 3. Fix ownership to `www-data` | ||
| 71 | 4. Write Caddy config and reload | ||
| 72 | |||
| 73 | ## Step 4 — Verify | ||
| 74 | |||
| 75 | After deploying, confirm the service came up: | ||
| 76 | |||
| 77 | **Binary:** | ||
| 78 | ```bash | ||
| 79 | ssh <host> "sudo systemctl is-active <app-name>" | ||
| 80 | ``` | ||
| 81 | |||
| 82 | If not active, immediately check logs: | ||
| 83 | ```bash | ||
| 84 | ssh <host> "sudo journalctl -u <app-name> -n 30 --no-pager" | ||
| 85 | ``` | ||
| 86 | |||
| 87 | **Static:** | ||
| 88 | ```bash | ||
| 89 | ssh <host> "curl -sI https://<domain> | head -5" | ||
| 90 | ``` | ||
| 91 | |||
| 92 | ## Step 5 — Confirm to user | ||
| 93 | |||
| 94 | Report: | ||
| 95 | - App name and live URL | ||
| 96 | - Type (binary / static) | ||
| 97 | - New deploy or update | ||
| 98 | - Port (binary only) | ||
| 99 | - Any SQLite backups made | ||
| 100 | - Any env vars that were set | ||
| 101 | - Whether the service is running | ||
| 102 | |||
| 103 | ## Checklist (reference) | ||
| 104 | |||
| 105 | Use this to make sure nothing is missed: | ||
| 106 | |||
| 107 | - [ ] Config file read, host resolved | ||
| 108 | - [ ] App type confirmed (binary / static) | ||
| 109 | - [ ] Artifact path confirmed and exists locally | ||
| 110 | - [ ] App name and domain confirmed | ||
| 111 | - [ ] Existing app check done | ||
| 112 | - [ ] SQLite backed up (binary, if db files exist) | ||
| 113 | - [ ] Port allocated or retrieved | ||
| 114 | - [ ] Artifact uploaded | ||
| 115 | - [ ] Env file written with merge semantics | ||
| 116 | - [ ] Systemd unit written and service started/restarted (binary) | ||
| 117 | - [ ] Caddy config written and reloaded | ||
| 118 | - [ ] Service confirmed running | ||
| 119 | |||
| 120 | ## Notes | ||
| 121 | |||
| 122 | - Never skip the SQLite backup step for binary apps — always check even if you don't expect a db | ||
| 123 | - Always merge env vars — never overwrite the whole env file | ||
| 124 | - If anything fails mid-deploy, tell the user exactly where it failed and what state the server is in | ||
| 125 | - Use `systemctl restart` for existing apps, `enable --now` for new ones — not the other way around | ||
| 126 | - If the user says "deploy X" without more context, ask the minimum necessary questions before starting | ||
diff --git a/skills/ship-env/SKILL.md b/skills/ship-env/SKILL.md new file mode 100644 index 0000000..692904d --- /dev/null +++ b/skills/ship-env/SKILL.md | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | --- | ||
| 2 | name: ship-env | ||
| 3 | description: Read or write environment variables for a deployed app. Always merges — never overwrites existing vars. Use when you need to set, update, or view env vars for an app without redeploying. | ||
| 4 | argument-hint: "<app-name> [KEY=VALUE ...]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-env | ||
| 8 | |||
| 9 | Read and write environment variables for a deployed app on a ship VPS. | ||
| 10 | Always merges new values into the existing file — existing vars are never lost. | ||
| 11 | |||
| 12 | ## Read Config | ||
| 13 | |||
| 14 | Load the host from `~/.config/ship/config.json`: | ||
| 15 | |||
| 16 | ```bash | ||
| 17 | python3 -c " | ||
| 18 | import json, os | ||
| 19 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 20 | nick = '<nickname-or-default>' | ||
| 21 | h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']]) | ||
| 22 | print(h['host']) | ||
| 23 | " | ||
| 24 | ``` | ||
| 25 | |||
| 26 | ## Usage Patterns | ||
| 27 | |||
| 28 | ### View current env vars for an app | ||
| 29 | |||
| 30 | ```bash | ||
| 31 | ssh <host> "sudo cat /etc/ship/env/<app-name>.env" | ||
| 32 | ``` | ||
| 33 | |||
| 34 | Show them to the user clearly. Remind them that PORT, SHIP_NAME, and SHIP_URL are | ||
| 35 | managed by ship and shouldn't be manually changed. | ||
| 36 | |||
| 37 | ### Set or update one or more vars | ||
| 38 | |||
| 39 | Given vars like `FOO=bar BAZ=qux`, merge them into the existing env file: | ||
| 40 | |||
| 41 | ```bash | ||
| 42 | # Read current env | ||
| 43 | ssh <host> "sudo cat /etc/ship/env/<app-name>.env 2>/dev/null" | ||
| 44 | ``` | ||
| 45 | |||
| 46 | Then write the merged result back: | ||
| 47 | |||
| 48 | ```bash | ||
| 49 | ssh <host> "sudo tee /etc/ship/env/<app-name>.env > /dev/null << 'EOF' | ||
| 50 | PORT=9013 | ||
| 51 | SHIP_NAME=myapp | ||
| 52 | SHIP_URL=https://myapp.example.com | ||
| 53 | FOO=bar | ||
| 54 | BAZ=qux | ||
| 55 | EOF" | ||
| 56 | ``` | ||
| 57 | |||
| 58 | **Merge rules:** | ||
| 59 | - Start with all existing vars | ||
| 60 | - Overwrite any keys that appear in the new set | ||
| 61 | - Add any new keys that didn't exist before | ||
| 62 | - Never remove existing keys unless explicitly asked | ||
| 63 | |||
| 64 | ### Delete a var | ||
| 65 | |||
| 66 | Only delete a var if the user explicitly asks to remove it by name. Show them the | ||
| 67 | current value and confirm before removing. | ||
| 68 | |||
| 69 | ### Restart after changes | ||
| 70 | |||
| 71 | After writing new env vars, ask the user if they want to restart the service to apply | ||
| 72 | them. If yes, use ship-service to restart: | ||
| 73 | |||
| 74 | ```bash | ||
| 75 | ssh <host> "sudo systemctl restart <app-name>" | ||
| 76 | ``` | ||
| 77 | |||
| 78 | ## Notes | ||
| 79 | |||
| 80 | - Env file lives at `/etc/ship/env/<app-name>.env` | ||
| 81 | - PORT, SHIP_NAME, and SHIP_URL are written by ship-binary/ship-deploy — don't remove them | ||
| 82 | - If the env file doesn't exist, the app probably isn't deployed yet — say so | ||
| 83 | - Use the default host unless a host nickname was specified (e.g. "set FOO=bar on staging") | ||
| 84 | - After updating, remind the user the service needs a restart to pick up changes | ||
diff --git a/skills/ship-service/SKILL.md b/skills/ship-service/SKILL.md new file mode 100644 index 0000000..e4d7510 --- /dev/null +++ b/skills/ship-service/SKILL.md | |||
| @@ -0,0 +1,133 @@ | |||
| 1 | --- | ||
| 2 | name: ship-service | ||
| 3 | description: Manage systemd services for deployed apps. Start, stop, restart, view status and logs. Use when you need to control a running service or diagnose problems. | ||
| 4 | argument-hint: "<app-name> [start|stop|restart|status|logs] [host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-service | ||
| 8 | |||
| 9 | Manage systemd services for apps deployed on a ship VPS. | ||
| 10 | |||
| 11 | ## Read Config | ||
| 12 | |||
| 13 | ```bash | ||
| 14 | python3 -c " | ||
| 15 | import json, os | ||
| 16 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 17 | nick = '<nickname-or-default>' | ||
| 18 | h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']]) | ||
| 19 | print(h['host']) | ||
| 20 | " | ||
| 21 | ``` | ||
| 22 | |||
| 23 | ## Usage Patterns | ||
| 24 | |||
| 25 | ### Status | ||
| 26 | |||
| 27 | ```bash | ||
| 28 | ssh <host> "sudo systemctl status <app-name> --no-pager" | ||
| 29 | ``` | ||
| 30 | |||
| 31 | Shows whether the service is running, its PID, memory usage, and recent log lines. | ||
| 32 | |||
| 33 | ### Restart | ||
| 34 | |||
| 35 | ```bash | ||
| 36 | ssh <host> "sudo systemctl restart <app-name>" | ||
| 37 | ``` | ||
| 38 | |||
| 39 | Use after changing env vars or replacing the binary. | ||
| 40 | |||
| 41 | ### Stop | ||
| 42 | |||
| 43 | ```bash | ||
| 44 | ssh <host> "sudo systemctl stop <app-name>" | ||
| 45 | ``` | ||
| 46 | |||
| 47 | ### Start | ||
| 48 | |||
| 49 | ```bash | ||
| 50 | ssh <host> "sudo systemctl start <app-name>" | ||
| 51 | ``` | ||
| 52 | |||
| 53 | ### View logs | ||
| 54 | |||
| 55 | Recent logs (last 50 lines): | ||
| 56 | ```bash | ||
| 57 | ssh <host> "sudo journalctl -u <app-name> -n 50 --no-pager" | ||
| 58 | ``` | ||
| 59 | |||
| 60 | Follow live logs: | ||
| 61 | ```bash | ||
| 62 | ssh <host> "sudo journalctl -u <app-name> -f" | ||
| 63 | ``` | ||
| 64 | |||
| 65 | Logs since last boot: | ||
| 66 | ```bash | ||
| 67 | ssh <host> "sudo journalctl -u <app-name> -b --no-pager" | ||
| 68 | ``` | ||
| 69 | |||
| 70 | ### Enable (start on boot) | ||
| 71 | |||
| 72 | ```bash | ||
| 73 | ssh <host> "sudo systemctl enable <app-name>" | ||
| 74 | ``` | ||
| 75 | |||
| 76 | ### Disable (don't start on boot) | ||
| 77 | |||
| 78 | ```bash | ||
| 79 | ssh <host> "sudo systemctl disable <app-name>" | ||
| 80 | ``` | ||
| 81 | |||
| 82 | ### View the systemd unit file | ||
| 83 | |||
| 84 | ```bash | ||
| 85 | ssh <host> "sudo cat /etc/systemd/system/<app-name>.service" | ||
| 86 | ``` | ||
| 87 | |||
| 88 | ### Remove a service entirely | ||
| 89 | |||
| 90 | Only do this if the user explicitly asks to remove/uninstall an app: | ||
| 91 | |||
| 92 | ```bash | ||
| 93 | ssh <host> "sudo systemctl stop <app-name> && sudo systemctl disable <app-name> && sudo rm /etc/systemd/system/<app-name>.service && sudo systemctl daemon-reload" | ||
| 94 | ``` | ||
| 95 | |||
| 96 | Confirm with the user before removing. Note that this does not delete the binary, | ||
| 97 | data directory, env file, or Caddy config — those are managed separately. | ||
| 98 | |||
| 99 | ## Diagnosing Problems | ||
| 100 | |||
| 101 | If a service is failing, check: | ||
| 102 | |||
| 103 | 1. Service status for the error: | ||
| 104 | ```bash | ||
| 105 | ssh <host> "sudo systemctl status <app-name> --no-pager" | ||
| 106 | ``` | ||
| 107 | |||
| 108 | 2. Full logs for context: | ||
| 109 | ```bash | ||
| 110 | ssh <host> "sudo journalctl -u <app-name> -n 100 --no-pager" | ||
| 111 | ``` | ||
| 112 | |||
| 113 | 3. Whether the binary exists and is executable: | ||
| 114 | ```bash | ||
| 115 | ssh <host> "ls -la /usr/local/bin/<app-name>" | ||
| 116 | ``` | ||
| 117 | |||
| 118 | 4. Whether the env file exists and looks correct: | ||
| 119 | ```bash | ||
| 120 | ssh <host> "sudo cat /etc/ship/env/<app-name>.env" | ||
| 121 | ``` | ||
| 122 | |||
| 123 | 5. Whether the port is already in use by something else: | ||
| 124 | ```bash | ||
| 125 | ssh <host> "sudo ss -tlnp | grep <port>" | ||
| 126 | ``` | ||
| 127 | |||
| 128 | ## Notes | ||
| 129 | |||
| 130 | - If the user just says "restart foodtracker" or "check the logs for myapp", infer the action | ||
| 131 | - After a restart, give it a moment then check status to confirm it came up | ||
| 132 | - If a service repeatedly crashes, look at the logs before suggesting a fix | ||
| 133 | - Use default host unless another is specified | ||
diff --git a/skills/ship-setup/SKILL.md b/skills/ship-setup/SKILL.md new file mode 100644 index 0000000..e3d08ee --- /dev/null +++ b/skills/ship-setup/SKILL.md | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | --- | ||
| 2 | name: ship-setup | ||
| 3 | description: Set up ship for the first time or add a new VPS host. Saves host config to ~/.config/ship/config.json and installs server dependencies. Use when configuring ship for the first time, or adding/changing a host. | ||
| 4 | argument-hint: "[host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-setup | ||
| 8 | |||
| 9 | Configure ship and prepare a VPS for deployments. | ||
| 10 | |||
| 11 | ## Config File | ||
| 12 | |||
| 13 | All ship skills read from `~/.config/ship/config.json`. This skill creates or updates it. | ||
| 14 | |||
| 15 | Structure: | ||
| 16 | ```json | ||
| 17 | { | ||
| 18 | "default": "prod", | ||
| 19 | "hosts": { | ||
| 20 | "prod": { | ||
| 21 | "host": "ubuntu@1.2.3.4", | ||
| 22 | "domain": "example.com" | ||
| 23 | }, | ||
| 24 | "staging": { | ||
| 25 | "host": "ubuntu@5.6.7.8", | ||
| 26 | "domain": "staging.example.com" | ||
| 27 | } | ||
| 28 | } | ||
| 29 | } | ||
| 30 | ``` | ||
| 31 | |||
| 32 | ## Steps | ||
| 33 | |||
| 34 | ### 1. Read existing config | ||
| 35 | |||
| 36 | Check if `~/.config/ship/config.json` exists: | ||
| 37 | |||
| 38 | ```bash | ||
| 39 | cat ~/.config/ship/config.json 2>/dev/null | ||
| 40 | ``` | ||
| 41 | |||
| 42 | If it exists, show the user the current hosts so they know what's already configured. | ||
| 43 | |||
| 44 | ### 2. Get host details | ||
| 45 | |||
| 46 | If no nickname was provided as an argument, ask the user: | ||
| 47 | - **Nickname** — a short name for this host (e.g. `prod`, `staging`, `vps`) | ||
| 48 | - **SSH connection string** — e.g. `ubuntu@1.2.3.4` or an SSH config alias like `alaskav6` | ||
| 49 | - **Base domain** — the domain pointing to this server (e.g. `example.com`) | ||
| 50 | |||
| 51 | If this is the first host, ask if it should be the default. If hosts already exist, ask if this should replace the current default. | ||
| 52 | |||
| 53 | ### 3. Test SSH connection | ||
| 54 | |||
| 55 | Verify the connection works before saving anything: | ||
| 56 | |||
| 57 | ```bash | ||
| 58 | ssh -o ConnectTimeout=5 <host> "echo ok" | ||
| 59 | ``` | ||
| 60 | |||
| 61 | If it fails, tell the user and stop. Don't save config for an unreachable host. | ||
| 62 | |||
| 63 | ### 4. Save config | ||
| 64 | |||
| 65 | Write or update `~/.config/ship/config.json` with the new host. Merge with existing hosts — never overwrite the whole file. Use Python to safely read/write JSON: | ||
| 66 | |||
| 67 | ```bash | ||
| 68 | python3 -c " | ||
| 69 | import json, os | ||
| 70 | path = os.path.expanduser('~/.config/ship/config.json') | ||
| 71 | os.makedirs(os.path.dirname(path), exist_ok=True) | ||
| 72 | cfg = json.load(open(path)) if os.path.exists(path) else {'default': None, 'hosts': {}} | ||
| 73 | cfg['hosts']['<nickname>'] = {'host': '<ssh-host>', 'domain': '<domain>'} | ||
| 74 | if not cfg['default']: | ||
| 75 | cfg['default'] = '<nickname>' | ||
| 76 | json.dump(cfg, open(path, 'w'), indent=2) | ||
| 77 | print('saved') | ||
| 78 | " | ||
| 79 | ``` | ||
| 80 | |||
| 81 | ### 5. Install server dependencies | ||
| 82 | |||
| 83 | SSH in and ensure the required directories and software exist. This is idempotent — safe to run multiple times. | ||
| 84 | |||
| 85 | **Install Caddy** (if not present): | ||
| 86 | ```bash | ||
| 87 | ssh <host> "which caddy || (sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list && sudo apt-get update && sudo apt-get install -y caddy)" | ||
| 88 | ``` | ||
| 89 | |||
| 90 | **Create directory structure:** | ||
| 91 | ```bash | ||
| 92 | ssh <host> "sudo mkdir -p /etc/ship/env /etc/ship/ports /etc/caddy/sites-enabled /var/www && sudo chmod 755 /etc/ship" | ||
| 93 | ``` | ||
| 94 | |||
| 95 | **Configure main Caddyfile** (only if not already set up): | ||
| 96 | ```bash | ||
| 97 | ssh <host> "sudo test -f /etc/caddy/Caddyfile && echo exists || echo '{ | ||
| 98 | } | ||
| 99 | |||
| 100 | import /etc/caddy/sites-enabled/*' | sudo tee /etc/caddy/Caddyfile" | ||
| 101 | ``` | ||
| 102 | |||
| 103 | **Enable and start Caddy:** | ||
| 104 | ```bash | ||
| 105 | ssh <host> "sudo systemctl enable caddy && sudo systemctl start caddy" | ||
| 106 | ``` | ||
| 107 | |||
| 108 | ### 6. Confirm | ||
| 109 | |||
| 110 | Tell the user: | ||
| 111 | - Host nickname and SSH target saved | ||
| 112 | - Whether it's the default host | ||
| 113 | - That the server is ready for deployments | ||
| 114 | - How to add another host: `/ship-setup <nickname>` | ||
| 115 | - How to deploy: `/ship-deploy` | ||
| 116 | |||
| 117 | ## Notes | ||
| 118 | |||
| 119 | - Never overwrite the entire config file — always merge | ||
| 120 | - If a nickname already exists in config, confirm before overwriting it | ||
| 121 | - The SSH host can be an alias from `~/.ssh/config` — no need to require raw IP | ||
| 122 | - Default host is used by all other ship skills when no host is specified | ||
diff --git a/skills/ship-static/SKILL.md b/skills/ship-static/SKILL.md new file mode 100644 index 0000000..1ef74d3 --- /dev/null +++ b/skills/ship-static/SKILL.md | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | --- | ||
| 2 | name: ship-static | ||
| 3 | description: Deploy a static site to a ship VPS. Rsyncs a local dist folder to the server and configures Caddy to serve it. Use when deploying a built frontend, docs site, or any folder of static files. | ||
| 4 | argument-hint: "<path-to-dist> <app-name> [host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-static | ||
| 8 | |||
| 9 | Deploy a static site by rsyncing a local directory to the server and configuring Caddy. | ||
| 10 | |||
| 11 | ## Read Config | ||
| 12 | |||
| 13 | ```bash | ||
| 14 | python3 -c " | ||
| 15 | import json, os | ||
| 16 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 17 | nick = '<nickname-or-default>' | ||
| 18 | h = cfg['hosts'].get(nick, cfg['hosts'][cfg['default']]) | ||
| 19 | print(h['host']) | ||
| 20 | print(h['domain']) | ||
| 21 | " | ||
| 22 | ``` | ||
| 23 | |||
| 24 | ## Inputs | ||
| 25 | |||
| 26 | - **Dist path** — local directory containing the built static files (e.g. `./dist`, `./out`) | ||
| 27 | - **App name** — short lowercase name, becomes the subdomain (e.g. `mysite`) | ||
| 28 | - **Domain** — defaults to `<app-name>.<base-domain>`, ask if different | ||
| 29 | - **Host** — use default unless specified | ||
| 30 | |||
| 31 | ## Steps | ||
| 32 | |||
| 33 | ### 1. Validate local path | ||
| 34 | |||
| 35 | Check that the dist directory exists and contains an `index.html`: | ||
| 36 | |||
| 37 | ```bash | ||
| 38 | ls <dist-path>/index.html | ||
| 39 | ``` | ||
| 40 | |||
| 41 | If not found, tell the user — they may need to build first. | ||
| 42 | |||
| 43 | ### 2. Create remote directory | ||
| 44 | |||
| 45 | ```bash | ||
| 46 | ssh <host> "sudo mkdir -p /var/www/<app-name> && sudo chown $USER:$USER /var/www/<app-name>" | ||
| 47 | ``` | ||
| 48 | |||
| 49 | ### 3. Sync files | ||
| 50 | |||
| 51 | ```bash | ||
| 52 | rsync -avz --delete <dist-path>/ <host>:/var/www/<app-name>/ | ||
| 53 | ``` | ||
| 54 | |||
| 55 | The `--delete` flag removes files on the server that no longer exist locally, keeping | ||
| 56 | the deployment clean. Tell the user how many files were transferred. | ||
| 57 | |||
| 58 | ### 4. Fix ownership | ||
| 59 | |||
| 60 | After rsync, ensure Caddy can read the files: | ||
| 61 | |||
| 62 | ```bash | ||
| 63 | ssh <host> "sudo chown -R www-data:www-data /var/www/<app-name>" | ||
| 64 | ``` | ||
| 65 | |||
| 66 | ### 5. Write Caddy config | ||
| 67 | |||
| 68 | Check if a config already exists: | ||
| 69 | |||
| 70 | ```bash | ||
| 71 | ssh <host> "cat /etc/caddy/sites-enabled/<app-name>.caddy 2>/dev/null" | ||
| 72 | ``` | ||
| 73 | |||
| 74 | Write (or overwrite) the config: | ||
| 75 | |||
| 76 | ```bash | ||
| 77 | ssh <host> "sudo tee /etc/caddy/sites-enabled/<app-name>.caddy > /dev/null << 'EOF' | ||
| 78 | <domain> { | ||
| 79 | root * /var/www/<app-name> | ||
| 80 | file_server | ||
| 81 | encode gzip | ||
| 82 | } | ||
| 83 | EOF" | ||
| 84 | ``` | ||
| 85 | |||
| 86 | ### 6. Validate and reload Caddy | ||
| 87 | |||
| 88 | ```bash | ||
| 89 | ssh <host> "sudo caddy validate --config /etc/caddy/Caddyfile 2>&1" | ||
| 90 | ``` | ||
| 91 | |||
| 92 | If valid: | ||
| 93 | ```bash | ||
| 94 | ssh <host> "sudo systemctl reload caddy" | ||
| 95 | ``` | ||
| 96 | |||
| 97 | If invalid, show the error and do not reload. | ||
| 98 | |||
| 99 | ### 7. Confirm | ||
| 100 | |||
| 101 | Tell the user: | ||
| 102 | - URL the site is live at | ||
| 103 | - Number of files synced | ||
| 104 | - Whether this was a new deployment or an update | ||
| 105 | |||
| 106 | ## Notes | ||
| 107 | |||
| 108 | - Build before deploying — this skill does not run build commands | ||
| 109 | - `--delete` in rsync means files removed locally will be removed from the server too | ||
| 110 | - If the user wants a custom domain, use ship-caddy to update the config after deploying | ||
| 111 | - For SPAs with client-side routing, the Caddy config may need a `try_files` directive: | ||
| 112 | ``` | ||
| 113 | <domain> { | ||
| 114 | root * /var/www/<app-name> | ||
| 115 | try_files {path} /index.html | ||
| 116 | file_server | ||
| 117 | encode gzip | ||
| 118 | } | ||
| 119 | ``` | ||
| 120 | Ask the user if their site uses client-side routing. | ||
| 121 | - Use default host unless another is specified | ||
diff --git a/skills/ship-status/SKILL.md b/skills/ship-status/SKILL.md new file mode 100644 index 0000000..f47e081 --- /dev/null +++ b/skills/ship-status/SKILL.md | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | --- | ||
| 2 | name: ship-status | ||
| 3 | description: Show what's running on a ship VPS. Derives state from the server — no local state file. Use when you want to know what apps are deployed, what ports they use, and whether they're running. | ||
| 4 | argument-hint: "[host-nickname]" | ||
| 5 | --- | ||
| 6 | |||
| 7 | # ship-status | ||
| 8 | |||
| 9 | Show the current state of all deployments on a ship VPS by reading directly from the server. | ||
| 10 | |||
| 11 | ## Read Config | ||
| 12 | |||
| 13 | Load the host from `~/.config/ship/config.json`. Use the default host unless a nickname was specified: | ||
| 14 | |||
| 15 | ```bash | ||
| 16 | python3 -c " | ||
| 17 | import json, os, sys | ||
| 18 | cfg = json.load(open(os.path.expanduser('~/.config/ship/config.json'))) | ||
| 19 | nick = sys.argv[1] if len(sys.argv) > 1 else cfg['default'] | ||
| 20 | h = cfg['hosts'][nick] | ||
| 21 | print(h['host']) | ||
| 22 | print(h['domain']) | ||
| 23 | " <nickname-or-blank> | ||
| 24 | ``` | ||
| 25 | |||
| 26 | ## Gather Server State | ||
| 27 | |||
| 28 | Run all of these in a single SSH session to minimize round trips: | ||
| 29 | |||
| 30 | **Systemd services (binary/docker apps):** | ||
| 31 | ```bash | ||
| 32 | ssh <host> "systemctl list-units --type=service --state=active --no-pager --no-legend | grep -v '@' | awk '{print \$1}' | xargs -I{} sh -c 'name=\$(echo {} | sed s/.service//); port=\$(cat /etc/ship/ports/\$name 2>/dev/null); env=\$(cat /etc/ship/env/\$name.env 2>/dev/null | grep SHIP_URL | cut -d= -f2); echo \"\$name|\$port|\$env\"' 2>/dev/null | grep '|'" | ||
| 33 | ``` | ||
| 34 | |||
| 35 | **Static sites:** | ||
| 36 | ```bash | ||
| 37 | ssh <host> "ls /var/www/ 2>/dev/null" | ||
| 38 | ``` | ||
| 39 | |||
| 40 | **Caddy configs (domains):** | ||
| 41 | ```bash | ||
| 42 | ssh <host> "ls /etc/caddy/sites-enabled/ 2>/dev/null | grep -v '^$'" | ||
| 43 | ``` | ||
| 44 | |||
| 45 | **Caddy status:** | ||
| 46 | ```bash | ||
| 47 | ssh <host> "systemctl is-active caddy" | ||
| 48 | ``` | ||
| 49 | |||
| 50 | **Disk usage for app data dirs:** | ||
| 51 | ```bash | ||
| 52 | ssh <host> "du -sh /var/lib/*/ 2>/dev/null" | ||
| 53 | ``` | ||
| 54 | |||
| 55 | ## Present Results | ||
| 56 | |||
| 57 | Format as a clean summary, for example: | ||
| 58 | |||
| 59 | ``` | ||
| 60 | HOST: prod (ubuntu@1.2.3.4) | ||
| 61 | |||
| 62 | SERVICES | ||
| 63 | foodtracker running :9013 https://foodtracker.example.com | ||
| 64 | myapi running :9014 https://api.example.com | ||
| 65 | |||
| 66 | STATIC SITES | ||
| 67 | mysite https://mysite.example.com | ||
| 68 | |||
| 69 | CADDY: running | ||
| 70 | |||
| 71 | DATA | ||
| 72 | /var/lib/foodtracker/ 48M | ||
| 73 | /var/lib/myapi/ 2M | ||
| 74 | ``` | ||
| 75 | |||
| 76 | If a service appears in `/etc/ship/ports/` but is not active in systemd, flag it as **stopped**. | ||
| 77 | |||
| 78 | If a Caddy config exists for a name but no service or static site matches, flag it as **orphaned config**. | ||
| 79 | |||
| 80 | ## Notes | ||
| 81 | |||
| 82 | - No local state file is consulted — everything comes from the server | ||
| 83 | - If `~/.config/ship/config.json` doesn't exist, tell the user to run `/ship-setup` first | ||
| 84 | - If a nickname is given that doesn't exist in config, list available nicknames | ||
| 85 | - Use default host if no argument given | ||
