aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-04-18 14:40:17 -0700
committerClawd <ai@clawd.bot>2026-04-18 14:40:17 -0700
commit778bef5ee6941056e06326d1eaaa6956d7307a85 (patch)
tree23b85f32fb69f85078b3debec08c1353694def6f
parenteb76b1f6e1697ef170fc45d25e81b21679ea7b0d (diff)
Remove Go implementation — ship is skills-only nowmain
The skills/ directory fully replaces the old Go CLI. Drop all Go source, build files, planning docs, and the stale SECURITY.md (which described the old git-user push-deploy model that no longer exists). Trim .gitignore to match the new tree.
-rw-r--r--.gitignore20
-rw-r--r--Makefile11
-rw-r--r--SECURITY.md55
-rw-r--r--SKILLS_PLAN.md135
-rw-r--r--cmd/ship/commands.go365
-rw-r--r--cmd/ship/deploy.go210
-rw-r--r--cmd/ship/deploy_impl.go394
-rw-r--r--cmd/ship/host.go445
-rw-r--r--cmd/ship/main.go10
-rw-r--r--cmd/ship/root.go98
-rw-r--r--go.mod14
-rw-r--r--go.sum16
-rw-r--r--internal/detect/detect.go105
-rw-r--r--internal/output/output.go226
-rw-r--r--internal/ssh/client.go393
-rw-r--r--internal/state/state.go106
-rw-r--r--internal/templates/templates.go358
-rw-r--r--templates/app.caddy.tmpl3
-rw-r--r--templates/service.tmpl17
-rw-r--r--templates/static.caddy.tmpl5
-rw-r--r--test/example-website/about.html42
-rw-r--r--test/example-website/index.html46
-rw-r--r--test/example-website/style.css156
-rw-r--r--website/index.html216
24 files changed, 4 insertions, 3442 deletions
diff --git a/.gitignore b/.gitignore
index a2dd99e..9de1885 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,4 @@
1# Binaries 1# Editors
2/ship
3/ship-new
4*.exe
5*.dll
6*.so
7*.dylib
8bin/
9
10# Test binary
11*.test
12
13# Go workspace file
14go.work
15
16# IDE
17.vscode/ 2.vscode/
18.idea/ 3.idea/
19*.swp 4*.swp
@@ -22,3 +7,6 @@ go.work
22 7
23# Claude local settings 8# Claude local settings
24.claude/ 9.claude/
10
11# Syncthing conflict files
12*.sync-conflict-*
diff --git a/Makefile b/Makefile
deleted file mode 100644
index abab996..0000000
--- a/Makefile
+++ /dev/null
@@ -1,11 +0,0 @@
1.PHONY: build install deploy-website
2
3build:
4 go build -o ./bin/ship ./cmd/ship
5
6install:
7 cp ./bin/ship /usr/local/bin/
8
9deploy-website:
10 ship website/ ship.northwest.io
11
diff --git a/SECURITY.md b/SECURITY.md
deleted file mode 100644
index 2d7a96e..0000000
--- a/SECURITY.md
+++ /dev/null
@@ -1,55 +0,0 @@
1# Security Model & Known Gaps
2
3Ship is a single-user VPS deployment tool. The threat model assumes:
4- You control the VPS and have root SSH access
5- You trust everyone who has SSH push access (their keys are copied to the `git` user)
6- The VPS runs only your own apps
7
8## Mitigations in place
9
10### App name validation
11All app/project names are validated against `^[a-z][a-z0-9-]{0,62}$` before being used in shell commands, file paths, systemd units, or DNS labels. This prevents command injection via crafted names.
12
13### Scoped sudoers
14The `git` user's sudo rules are restricted to specific paths:
15- `systemctl restart/enable` only for services matching `[a-z]*`
16- `cp` only from `.ship/` subdirectories to `/etc/systemd/system/` and `/etc/caddy/sites-enabled/`
17- `mkdir` only under `/var/lib/` and `/var/www/`
18- `chown` only for `git:git` under `/var/lib/` and `/var/www/`
19
20### Scoped safe.directory
21Git's `safe.directory` is set only for the `www-data` user (not system-wide), preserving CVE-2022-24765 protection for other users.
22
23## Accepted risks (by design)
24
25### SSH key access = root access
26The `git` user is in the `docker` group, which is root-equivalent (can mount the host filesystem). Additionally, `.ship/service` files pushed via git are installed as systemd units. Anyone with SSH push access effectively has root. This is intentional for a single-user tool.
27
28### Git repo visibility
29Repos are private by default (not cloneable over HTTPS). Use `ship init --public` to make a repo publicly cloneable. This is controlled by the `git-daemon-export-ok` marker file in each bare repo. Only public repos are accessible via `go get` or `git clone` over HTTPS. The cgit web interface respects the same model — it is configured with `export-ok=git-daemon-export-ok`, so only public repos are browsable.
30
31### User-controlled systemd units
32The `.ship/service` file in each repo is copied to `/etc/systemd/system/` on push. A malicious service file could run arbitrary commands as root. This is equivalent to the Docker access risk above.
33
34## Known gaps (not yet addressed)
35
36### SSH host key verification disabled
37`ssh.InsecureIgnoreHostKey()` is used for all SSH connections, and `StrictHostKeyChecking=no` for scp/rsync. This makes connections vulnerable to MITM attacks on untrusted networks. A future improvement would use `known_hosts` verification.
38
39### Env files may have loose permissions
40Environment files at `/etc/ship/env/{name}.env` are created via `sudo tee` and may be world-readable depending on umask. These files can contain secrets. The `deploy` flow does `chmod 600` but `ship init` does not. A future improvement would ensure consistent restrictive permissions.
41
42### host init is not idempotent
43Running `ship host init` twice will overwrite `/etc/caddy/Caddyfile` and the base domain Caddy config, destroying any manual edits. No guard checks whether setup has already been completed.
44
45### No rollback on failed docker build
46The post-receive hook installs `.ship/service` and `.ship/Caddyfile` before running `docker build`. If the build fails, the configs are updated but the old image is still running, creating a mismatch. The old container keeps running (due to `set -e`), but a manual restart would use the new (mismatched) unit file.
47
48### ship deploy vs git push ownership mismatch
49`ship deploy` runs commands as root (the SSH user), while `git push` triggers the hook as the `git` user. Files checked out by `ship deploy` become root-owned, which can prevent subsequent `git push` deploys from overwriting them.
50
51### No concurrent push protection
52Simultaneous pushes can race on the checkout directory and docker build. For single-user usage this is unlikely but not impossible.
53
54### Port allocation is monotonic
55Ports are never reclaimed when apps are removed. After ~57,000 create/remove cycles, ports would be exhausted. Not a practical concern.
diff --git a/SKILLS_PLAN.md b/SKILLS_PLAN.md
deleted file mode 100644
index ded2b38..0000000
--- a/SKILLS_PLAN.md
+++ /dev/null
@@ -1,135 +0,0 @@
1# Ship Skills — Reimagining Ship as Claude Skills
2
3## The Idea
4
5Rather than a monolithic CLI that bakes in rigid assumptions, ship becomes a family of
6narrow, composable Claude skills. Each skill knows how to do one thing well. Claude
7provides the reasoning and orchestration. The server is the source of truth.
8
9Skills are completely generic — no hostnames, app names, or passwords baked in. The
10same skills work for anyone. Share them with a friend, point them at a different VPS,
11they just work.
12
13## Shared Configuration
14
15A single static file at `~/.config/ship/config.json` holds the VPS host (and little
16else). 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
25The server itself is the source of truth for everything else — what services are
26running, what ports are allocated, what Caddy configs exist. No local state file that
27can go stale.
28
29## The Skills
30
31### `ship-setup`
32One-time setup. Asks for VPS host if not configured, saves to `~/.config/ship/config.json`,
33SSHes in and installs server dependencies (Caddy, directory structure, etc).
34All other skills depend on this having been run once.
35
36### `ship-status`
37Derives 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
43No state file needed. Always accurate. Replaces the need for any local tracking.
44
45### `ship-env`
46Read and write env vars with merge semantics. Never overwrites — reads existing file
47first, merges new values on top, writes result. Old vars survive redeployments.
48
49### `ship-caddy`
50Manage per-app Caddyfile config. Knows Caddy syntax. Diffs before writing. Validates
51before reloading. Never regenerates from scratch — only touches what needs changing.
52
53### `ship-service`
54Systemd management. Handles the difference between a new service (enable + start) and
55an existing one (restart). Status, logs, restart, stop — all covered.
56
57### `ship-binary`
58Upload and install a pre-built binary. SCP to `/tmp`, move to `/usr/local/bin/`,
59chmod +x, set up work directory and service user. Calls `ship-service` and `ship-env`
60to complete the deployment.
61
62### `ship-static`
63Rsync a local dist folder to `/var/www/{name}` on the server. Calls `ship-caddy` to
64configure serving.
65
66### `ship-deploy`
67A runbook skill that orchestrates the others in the right order for a full deployment.
68Not imperative code — just a checklist of steps with enough context for Claude to
69reason about what to do. Adapts based on what the user tells it (binary vs static,
70what env vars are needed, etc).
71
72## What the Server Knows
73
74All 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
97The server can't know things like "this app needs FOODTRACKER_PASSWORD on redeploy"
98or "this app has SQLite at /var/lib/foodtracker/data/". That's documentation, not
99state. Users can keep these as plain notes in whatever system they prefer — a vault,
100a README, a comment in a script. The skills don't depend on it.
101
102## SQLite Backup
103
104Before swapping a binary, `ship-binary` checks `/var/lib/{name}/` for any `.db` files
105and backs them up to `/var/lib/{name}/backups/{timestamp}.db` before proceeding. Silent
106and automatic — you never lose data from a bad deploy.
107
108## Multi-Host Support
109
110Config supports multiple named hosts. One is marked as default. All skills use the
111default 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
129Usage 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
131at any time.
132
133## Out of Scope (For Now)
134
135- Health checks — skipping initially, can add later if needed
diff --git a/cmd/ship/commands.go b/cmd/ship/commands.go
deleted file mode 100644
index 1b0d09c..0000000
--- a/cmd/ship/commands.go
+++ /dev/null
@@ -1,365 +0,0 @@
1package main
2
3import (
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
16var listV2Cmd = &cobra.Command{
17 Use: "list",
18 Short: "List all deployments",
19 RunE: runListV2,
20}
21
22func 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
141var statusV2Cmd = &cobra.Command{
142 Use: "status NAME",
143 Short: "Check status of a deployment",
144 Args: cobra.ExactArgs(1),
145 RunE: runStatusV2,
146}
147
148func 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
227var logsV2Cmd = &cobra.Command{
228 Use: "logs NAME",
229 Short: "View logs for a deployment",
230 Args: cobra.ExactArgs(1),
231 RunE: runLogsV2,
232}
233
234func init() {
235 logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show")
236}
237
238func 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
300var removeV2Cmd = &cobra.Command{
301 Use: "remove NAME",
302 Short: "Remove a deployment",
303 Args: cobra.ExactArgs(1),
304 RunE: runRemoveV2,
305}
306
307func runRemoveV2(cmd *cobra.Command, args []string) error {
308 name := args[0]
309
310 st, err := state.Load()
311 if err != nil {
312 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
313 }
314
315 hostName := hostFlag
316 if hostName == "" {
317 hostName = st.DefaultHost
318 }
319 if hostName == "" {
320 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
321 }
322
323 client, err := ssh.Connect(hostName)
324 if err != nil {
325 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
326 }
327 defer client.Close()
328
329 // Check if deployment exists
330 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
331 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
332
333 if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" {
334 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
335 }
336
337 // Stop and disable service
338 client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name))
339 client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name))
340
341 // Remove files
342 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
343 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
344 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
345 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
346 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
347 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
348 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name))
349 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name))
350
351 // Remove docker container and image
352 client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name))
353 client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name))
354
355 // Reload services
356 client.RunSudo("systemctl daemon-reload")
357 client.RunSudo("systemctl reload caddy")
358
359 output.PrintAndExit(&output.RemoveResponse{
360 Status: "ok",
361 Name: name,
362 Removed: true,
363 })
364 return nil
365}
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go
deleted file mode 100644
index 7d498b2..0000000
--- a/cmd/ship/deploy.go
+++ /dev/null
@@ -1,210 +0,0 @@
1package main
2
3import (
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.
19func 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
145type 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
158type 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
168func 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
179func 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
186func parseTTL(s string) (time.Duration, error) {
187 s = strings.TrimSpace(s)
188 if s == "" {
189 return 0, nil
190 }
191
192 // Handle days specially (not supported by time.ParseDuration)
193 if strings.HasSuffix(s, "d") {
194 days := strings.TrimSuffix(s, "d")
195 var d int
196 _, err := fmt.Sscanf(days, "%d", &d)
197 if err != nil {
198 return 0, fmt.Errorf("invalid TTL: %s", s)
199 }
200 return time.Duration(d) * 24 * time.Hour, nil
201 }
202
203 d, err := time.ParseDuration(s)
204 if err != nil {
205 return 0, fmt.Errorf("invalid TTL: %s", s)
206 }
207 return d, nil
208}
209
210// Deploy implementations are in deploy_impl_v2.go
diff --git a/cmd/ship/deploy_impl.go b/cmd/ship/deploy_impl.go
deleted file mode 100644
index bfec9d3..0000000
--- a/cmd/ship/deploy_impl.go
+++ /dev/null
@@ -1,394 +0,0 @@
1package main
2
3import (
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
19func 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
82func deployDockerV2(ctx *deployContext) *output.ErrorResponse {
83 client, err := ssh.Connect(ctx.SSHHost)
84 if err != nil {
85 return output.Err(output.ErrSSHConnectFailed, err.Error())
86 }
87 defer client.Close()
88
89 name := ctx.Name
90
91 // Allocate port on server
92 port, err := allocatePort(client, name)
93 if err != nil {
94 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
95 }
96
97 srcPath := fmt.Sprintf("/var/lib/%s/src", name)
98 dataPath := fmt.Sprintf("/var/lib/%s/data", name)
99
100 // Create directories
101 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil {
102 return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error())
103 }
104
105 // Set ownership for upload
106 user, _ := client.Run("whoami")
107 user = strings.TrimSpace(user)
108 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil {
109 return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error())
110 }
111
112 // Upload source
113 if err := client.UploadDir(ctx.Path, srcPath); err != nil {
114 return output.Err(output.ErrUploadFailed, err.Error())
115 }
116
117 // Docker build
118 buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath)
119 if _, err := client.RunSudo(buildCmd); err != nil {
120 return output.Err(output.ErrBuildFailed, err.Error())
121 }
122
123 // Determine container port
124 containerPort := ctx.Opts.ContainerPort
125 if containerPort == 0 {
126 containerPort = 80
127 }
128
129 // Generate and write env file
130 // Use containerPort so the app listens on the correct port inside the container
131 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL)
132 for _, e := range ctx.Opts.Env {
133 envContent += e + "\n"
134 }
135 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
136 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
137 // Continue, directory might exist
138 }
139 if err := client.WriteSudoFile(envPath, envContent); err != nil {
140 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
141 }
142
143 // Generate systemd unit
144 service, err := templates.DockerService(map[string]string{
145 "Name": name,
146 "Port": strconv.Itoa(port),
147 "ContainerPort": strconv.Itoa(containerPort),
148 })
149 if err != nil {
150 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
151 }
152
153 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
154 if err := client.WriteSudoFile(servicePath, service); err != nil {
155 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
156 }
157
158 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
159 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
160 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
161 if strings.TrimSpace(caddyExists) != "exists" {
162 caddyfile, err := templates.AppCaddy(map[string]string{
163 "Domain": ctx.URL[8:], // Strip https://
164 "Port": strconv.Itoa(port),
165 })
166 if err != nil {
167 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
168 }
169
170 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
171 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
172 }
173 }
174
175 // Reload systemd and start service
176 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
177 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
178 }
179 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
180 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
181 }
182
183 // Reload Caddy
184 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
185 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
186 }
187
188 return nil
189}
190
191// deployBinaryV2 deploys a pre-built binary
192// 1. Allocate port
193// 2. scp binary to /usr/local/bin/<name>
194// 3. Create user for service
195// 4. Generate systemd unit and env file
196// 5. Generate Caddyfile
197// 6. Start service, reload Caddy
198func deployBinaryV2(ctx *deployContext) *output.ErrorResponse {
199 client, err := ssh.Connect(ctx.SSHHost)
200 if err != nil {
201 return output.Err(output.ErrSSHConnectFailed, err.Error())
202 }
203 defer client.Close()
204
205 name := ctx.Name
206
207 // Allocate port on server
208 port, err := allocatePort(client, name)
209 if err != nil {
210 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
211 }
212
213 binaryPath := fmt.Sprintf("/usr/local/bin/%s", name)
214 workDir := fmt.Sprintf("/var/lib/%s", name)
215
216 // Upload binary
217 if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil {
218 return output.Err(output.ErrUploadFailed, err.Error())
219 }
220
221 // Move to final location and set permissions
222 if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil {
223 return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error())
224 }
225
226 // Create work directory
227 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
228 return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error())
229 }
230
231 // Create service user (ignore error if exists)
232 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name))
233 client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir))
234
235 // Generate and write env file
236 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
237 for _, e := range ctx.Opts.Env {
238 envContent += e + "\n"
239 }
240 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
241 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
242 // Continue
243 }
244 if err := client.WriteSudoFile(envPath, envContent); err != nil {
245 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
246 }
247
248 // Generate systemd unit
249 service, err := templates.SystemdService(map[string]string{
250 "Name": name,
251 "User": name,
252 "WorkDir": workDir,
253 "EnvFile": envPath,
254 "BinaryPath": binaryPath,
255 "Args": "",
256 })
257 if err != nil {
258 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
259 }
260
261 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
262 if err := client.WriteSudoFile(servicePath, service); err != nil {
263 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
264 }
265
266 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
267 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
268 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
269 if strings.TrimSpace(caddyExists) != "exists" {
270 caddyfile, err := templates.AppCaddy(map[string]string{
271 "Domain": ctx.URL[8:], // Strip https://
272 "Port": strconv.Itoa(port),
273 })
274 if err != nil {
275 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
276 }
277
278 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
279 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
280 }
281 }
282
283 // Reload systemd and start service
284 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
285 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
286 }
287 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil {
288 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
289 }
290
291 // Reload Caddy
292 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
293 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
294 }
295
296 return nil
297}
298
299// allocatePort allocates or retrieves a port for a service
300// Uses atomic increment on /etc/ship/next_port to avoid collisions
301func allocatePort(client *ssh.Client, name string) (int, error) {
302 portFile := fmt.Sprintf("/etc/ship/ports/%s", name)
303
304 // Try to read existing port for this app
305 out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile))
306 if err == nil && out != "" {
307 out = strings.TrimSpace(out)
308 if port, err := strconv.Atoi(out); err == nil && port > 0 {
309 return port, nil
310 }
311 }
312
313 // Allocate new port atomically using flock
314 // Scans existing port files to avoid collisions even if next_port is stale
315 allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'`
316 out, err = client.RunSudo(allocScript)
317 if err != nil {
318 return 0, fmt.Errorf("failed to allocate port: %w", err)
319 }
320
321 port, err := strconv.Atoi(strings.TrimSpace(out))
322 if err != nil {
323 return 0, fmt.Errorf("invalid port allocated: %s", out)
324 }
325
326 // Write port allocation for this app
327 if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil {
328 return 0, err
329 }
330
331 return port, nil
332}
333
334// setTTLV2 sets auto-expiry for a deploy
335func setTTLV2(ctx *deployContext, ttl time.Duration) error {
336 client, err := ssh.Connect(ctx.SSHHost)
337 if err != nil {
338 return err
339 }
340 defer client.Close()
341
342 expires := time.Now().Add(ttl).Unix()
343 ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name)
344
345 if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil {
346 return err
347 }
348
349 return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10))
350}
351
352// runHealthCheck verifies the deploy is responding
353func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
354 fullURL := url + endpoint
355
356 // Wait for app to start
357 time.Sleep(2 * time.Second)
358
359 var lastErr error
360 var lastStatus int
361
362 for i := 0; i < 15; i++ {
363 start := time.Now()
364 resp, err := http.Get(fullURL)
365 latency := time.Since(start).Milliseconds()
366
367 if err != nil {
368 lastErr = err
369 time.Sleep(2 * time.Second)
370 continue
371 }
372 resp.Body.Close()
373 lastStatus = resp.StatusCode
374
375 if resp.StatusCode >= 200 && resp.StatusCode < 400 {
376 return &output.HealthResult{
377 Endpoint: endpoint,
378 Status: resp.StatusCode,
379 LatencyMs: latency,
380 }, nil
381 }
382
383 time.Sleep(2 * time.Second)
384 }
385
386 msg := fmt.Sprintf("health check failed after 30s: ")
387 if lastErr != nil {
388 msg += lastErr.Error()
389 } else {
390 msg += fmt.Sprintf("status %d", lastStatus)
391 }
392
393 return nil, output.Err(output.ErrHealthCheckFailed, msg)
394}
diff --git a/cmd/ship/host.go b/cmd/ship/host.go
deleted file mode 100644
index b19c376..0000000
--- a/cmd/ship/host.go
+++ /dev/null
@@ -1,445 +0,0 @@
1package main
2
3import (
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
18func 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
26var 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
31Example:
32 ship host init user@my-vps --domain example.com`,
33 Args: cobra.ExactArgs(1),
34 RunE: runHostInitV2,
35}
36
37func 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
93import /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
159func 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
178func 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
197func installCleanupTimer(client *ssh.Client) error {
198 // Cleanup script
199 script := `#!/bin/bash
200now=$(date +%s)
201for 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
219done
220systemctl daemon-reload
221systemctl 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]
232Description=Ship TTL cleanup timer
233
234[Timer]
235OnCalendar=hourly
236Persistent=true
237
238[Install]
239WantedBy=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]
247Description=Ship TTL cleanup
248
249[Service]
250Type=oneshot
251ExecStart=/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
268var 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
309func 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.
390func 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
442func printJSON(v interface{}) {
443 enc := json.NewEncoder(os.Stdout)
444 enc.Encode(v)
445}
diff --git a/cmd/ship/main.go b/cmd/ship/main.go
deleted file mode 100644
index 17516fb..0000000
--- a/cmd/ship/main.go
+++ /dev/null
@@ -1,10 +0,0 @@
1package main
2
3import "os"
4
5func main() {
6 initV2()
7 if err := rootV2Cmd.Execute(); err != nil {
8 os.Exit(1)
9 }
10}
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
deleted file mode 100644
index aa81d1e..0000000
--- a/cmd/ship/root.go
+++ /dev/null
@@ -1,98 +0,0 @@
1package main
2
3import (
4 "os"
5
6 "github.com/bdw/ship/internal/output"
7 "github.com/spf13/cobra"
8)
9
10var 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
16var 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
26All 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
34func 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
64func 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
93var 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/go.mod b/go.mod
deleted file mode 100644
index cc84806..0000000
--- a/go.mod
+++ /dev/null
@@ -1,14 +0,0 @@
1module github.com/bdw/ship
2
3go 1.21
4
5require (
6 github.com/spf13/cobra v1.10.2
7 golang.org/x/crypto v0.31.0
8)
9
10require (
11 github.com/inconshreveable/mousetrap v1.1.0 // indirect
12 github.com/spf13/pflag v1.0.9 // indirect
13 golang.org/x/sys v0.28.0 // indirect
14)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 42e3e06..0000000
--- a/go.sum
+++ /dev/null
@@ -1,16 +0,0 @@
1github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
6github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
7github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
8github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
10golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
11golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
12golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
13golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
14golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
15golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
16gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/detect/detect.go b/internal/detect/detect.go
deleted file mode 100644
index f3efd22..0000000
--- a/internal/detect/detect.go
+++ /dev/null
@@ -1,105 +0,0 @@
1// Package detect provides auto-detection of project types.
2package detect
3
4import (
5 "os"
6 "path/filepath"
7
8 "github.com/bdw/ship/internal/output"
9)
10
11// ProjectType represents the detected deployment type
12type ProjectType string
13
14const (
15 TypeStatic ProjectType = "static"
16 TypeDocker ProjectType = "docker"
17 TypeBinary ProjectType = "binary"
18)
19
20// Result is the detection outcome
21type 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
34func 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
57func 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
65func 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
102func hasFile(dir, name string) bool {
103 _, err := os.Stat(filepath.Join(dir, name))
104 return err == nil
105}
diff --git a/internal/output/output.go b/internal/output/output.go
deleted file mode 100644
index a9a1036..0000000
--- a/internal/output/output.go
+++ /dev/null
@@ -1,226 +0,0 @@
1// Package output provides JSON response types for ship v2.
2// All commands output JSON by default. Human-readable output is opt-in via --pretty.
3package output
4
5import (
6 "encoding/json"
7 "fmt"
8 "os"
9)
10
11// Response is the base interface for all output types
12type Response interface {
13 IsError() bool
14}
15
16// DeployResponse is returned on successful deploy
17type 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
27func (r DeployResponse) IsError() bool { return false }
28
29// HealthResult is the health check outcome
30type 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
37type ListResponse struct {
38 Status string `json:"status"`
39 Deploys []DeployInfo `json:"deploys"`
40}
41
42func (r ListResponse) IsError() bool { return false }
43
44// DeployInfo is a single deploy in a list
45type 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
54type 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
66func (r StatusResponse) IsError() bool { return false }
67
68// LogsResponse is returned by ship logs
69type LogsResponse struct {
70 Status string `json:"status"`
71 Name string `json:"name"`
72 Lines []string `json:"lines"`
73}
74
75func (r LogsResponse) IsError() bool { return false }
76
77// RemoveResponse is returned by ship remove
78type RemoveResponse struct {
79 Status string `json:"status"`
80 Name string `json:"name"`
81 Removed bool `json:"removed"`
82}
83
84func (r RemoveResponse) IsError() bool { return false }
85
86// HostInitResponse is returned by ship host init
87type HostInitResponse struct {
88 Status string `json:"status"`
89 Host string `json:"host"`
90 Domain string `json:"domain"`
91 Installed []string `json:"installed"`
92}
93
94func (r HostInitResponse) IsError() bool { return false }
95
96// ErrorResponse is returned on any failure
97type 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
105func (r ErrorResponse) IsError() bool { return true }
106
107// Error implements the error interface for compatibility with v1 code
108func (r *ErrorResponse) Error() string { return r.Message }
109
110// Error codes
111const (
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
132const (
133 ExitSuccess = 0
134 ExitDeployFailed = 1
135 ExitInvalidArgs = 2
136 ExitSSHFailed = 3
137 ExitHealthFailed = 4
138)
139
140// Pretty controls human-readable output
141var Pretty bool
142
143// Print outputs the response as JSON (or pretty if enabled)
144func 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
154func 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
163func 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
172func ErrWithName(code, message, name string) *ErrorResponse {
173 return &ErrorResponse{
174 Status: "error",
175 Code: code,
176 Message: message,
177 Name: name,
178 }
179}
180
181func 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
194func printPretty(r Response) {
195 switch v := r.(type) {
196 case *DeployResponse:
197 fmt.Printf("✓ Deployed to %s (%.1fs)\n", v.URL, float64(v.TookMs)/1000)
198 case *ListResponse:
199 if len(v.Deploys) == 0 {
200 fmt.Println("No deployments")
201 return
202 }
203 fmt.Printf("%-20s %-40s %-8s %s\n", "NAME", "URL", "TYPE", "STATUS")
204 for _, d := range v.Deploys {
205 status := "running"
206 if !d.Running {
207 status = "stopped"
208 }
209 if d.Expires != "" {
210 status += " (expires " + d.Expires + ")"
211 }
212 fmt.Printf("%-20s %-40s %-8s %s\n", d.Name, d.URL, d.Type, status)
213 }
214 case *RemoveResponse:
215 fmt.Printf("✓ Removed %s\n", v.Name)
216 case *ErrorResponse:
217 fmt.Printf("✗ %s: %s\n", v.Code, v.Message)
218 case *HostInitResponse:
219 fmt.Printf("✓ Initialized %s with domain %s\n", v.Host, v.Domain)
220 default:
221 // Fallback to JSON
222 enc := json.NewEncoder(os.Stdout)
223 enc.SetIndent("", " ")
224 enc.Encode(r)
225 }
226}
diff --git a/internal/ssh/client.go b/internal/ssh/client.go
deleted file mode 100644
index b9c8d0f..0000000
--- a/internal/ssh/client.go
+++ /dev/null
@@ -1,393 +0,0 @@
1package ssh
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14 "golang.org/x/crypto/ssh/agent"
15)
16
17// Client represents an SSH connection to a remote host
18type Client struct {
19 host string
20 client *ssh.Client
21}
22
23// sshConfig holds SSH configuration for a host
24type sshConfig struct {
25 Host string
26 HostName string
27 User string
28 Port string
29 IdentityFile string
30}
31
32// Connect establishes an SSH connection to the remote host
33// Supports both SSH config aliases (e.g., "myserver") and user@host format
34func Connect(host string) (*Client, error) {
35 var user, addr string
36 var identityFile string
37
38 // Try to read SSH config first
39 cfg, err := readSSHConfig(host)
40 if err == nil && cfg.HostName != "" {
41 // Use SSH config
42 user = cfg.User
43 addr = cfg.HostName
44 if cfg.Port != "" {
45 addr = addr + ":" + cfg.Port
46 } else {
47 addr = addr + ":22"
48 }
49 identityFile = cfg.IdentityFile
50 } else {
51 // Fall back to parsing user@host format
52 parts := strings.SplitN(host, "@", 2)
53 if len(parts) != 2 {
54 return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host)
55 }
56 user = parts[0]
57 addr = parts[1]
58
59 // Add default port if not specified
60 if !strings.Contains(addr, ":") {
61 addr = addr + ":22"
62 }
63 }
64
65 // Build authentication methods
66 var authMethods []ssh.AuthMethod
67
68 // Try identity file from SSH config first
69 if identityFile != "" {
70 if authMethod, err := publicKeyFromFile(identityFile); err == nil {
71 authMethods = append(authMethods, authMethod)
72 }
73 }
74
75 // Try SSH agent
76 if authMethod, err := sshAgent(); err == nil {
77 authMethods = append(authMethods, authMethod)
78 }
79
80 // Try default key files
81 if authMethod, err := publicKeyFile(); err == nil {
82 authMethods = append(authMethods, authMethod)
83 }
84
85 if len(authMethods) == 0 {
86 return nil, fmt.Errorf("no SSH authentication method available")
87 }
88
89 config := &ssh.ClientConfig{
90 User: user,
91 Auth: authMethods,
92 HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts
93 }
94
95 client, err := ssh.Dial("tcp", addr, config)
96 if err != nil {
97 return nil, fmt.Errorf("failed to connect to %s: %w", host, err)
98 }
99
100 return &Client{
101 host: host,
102 client: client,
103 }, nil
104}
105
106// Close closes the SSH connection
107func (c *Client) Close() error {
108 return c.client.Close()
109}
110
111// Run executes a command on the remote host and returns the output
112func (c *Client) Run(cmd string) (string, error) {
113 session, err := c.client.NewSession()
114 if err != nil {
115 return "", err
116 }
117 defer session.Close()
118
119 var stdout, stderr bytes.Buffer
120 session.Stdout = &stdout
121 session.Stderr = &stderr
122
123 if err := session.Run(cmd); err != nil {
124 return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String())
125 }
126
127 return stdout.String(), nil
128}
129
130// RunSudo executes a command with sudo on the remote host
131func (c *Client) RunSudo(cmd string) (string, error) {
132 return c.Run("sudo " + cmd)
133}
134
135// RunSudoStream executes a command with sudo and streams output to stdout/stderr
136func (c *Client) RunSudoStream(cmd string) error {
137 session, err := c.client.NewSession()
138 if err != nil {
139 return err
140 }
141 defer session.Close()
142
143 session.Stdout = os.Stdout
144 session.Stderr = os.Stderr
145
146 if err := session.Run("sudo " + cmd); err != nil {
147 return fmt.Errorf("command failed: %w", err)
148 }
149
150 return nil
151}
152
153// RunStream executes a command and streams output to stdout/stderr
154func (c *Client) RunStream(cmd string) error {
155 session, err := c.client.NewSession()
156 if err != nil {
157 return err
158 }
159 defer session.Close()
160
161 session.Stdout = os.Stdout
162 session.Stderr = os.Stderr
163
164 if err := session.Run(cmd); err != nil {
165 return fmt.Errorf("command failed: %w", err)
166 }
167
168 return nil
169}
170
171// Upload copies a local file to the remote host using scp
172func (c *Client) Upload(localPath, remotePath string) error {
173 // Use external scp command for simplicity
174 // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath
175 cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath)
176
177 var stderr bytes.Buffer
178 cmd.Stderr = &stderr
179
180 if err := cmd.Run(); err != nil {
181 return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String())
182 }
183
184 return nil
185}
186
187// UploadDir copies a local directory to the remote host using rsync
188func (c *Client) UploadDir(localDir, remoteDir string) error {
189 // Use rsync for directory uploads
190 // Format: rsync -avz --delete localDir/ user@host:remoteDir/
191 localDir = strings.TrimSuffix(localDir, "/") + "/"
192 remoteDir = strings.TrimSuffix(remoteDir, "/") + "/"
193
194 cmd := exec.Command("rsync", "-avz", "--delete",
195 "-e", "ssh -o StrictHostKeyChecking=no",
196 localDir, c.host+":"+remoteDir)
197
198 var stderr bytes.Buffer
199 cmd.Stderr = &stderr
200
201 if err := cmd.Run(); err != nil {
202 return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String())
203 }
204
205 return nil
206}
207
208// WriteFile creates a file with the given content on the remote host
209func (c *Client) WriteFile(remotePath, content string) error {
210 session, err := c.client.NewSession()
211 if err != nil {
212 return err
213 }
214 defer session.Close()
215
216 // Use cat to write content to file
217 cmd := fmt.Sprintf("cat > %s", remotePath)
218 session.Stdin = strings.NewReader(content)
219
220 var stderr bytes.Buffer
221 session.Stderr = &stderr
222
223 if err := session.Run(cmd); err != nil {
224 return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String())
225 }
226
227 return nil
228}
229
230// WriteSudoFile creates a file with the given content using sudo
231func (c *Client) WriteSudoFile(remotePath, content string) error {
232 session, err := c.client.NewSession()
233 if err != nil {
234 return err
235 }
236 defer session.Close()
237
238 // Use sudo tee to write content to file
239 cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath)
240 session.Stdin = strings.NewReader(content)
241
242 var stderr bytes.Buffer
243 session.Stderr = &stderr
244
245 if err := session.Run(cmd); err != nil {
246 return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String())
247 }
248
249 return nil
250}
251
252// readSSHConfig reads and parses the SSH config file for a given host
253func readSSHConfig(host string) (*sshConfig, error) {
254 home, err := os.UserHomeDir()
255 if err != nil {
256 return nil, err
257 }
258
259 configPath := filepath.Join(home, ".ssh", "config")
260 file, err := os.Open(configPath)
261 if err != nil {
262 return nil, err
263 }
264 defer file.Close()
265
266 cfg := &sshConfig{}
267 var currentHost string
268 var matchedHost bool
269
270 scanner := bufio.NewScanner(file)
271 for scanner.Scan() {
272 line := strings.TrimSpace(scanner.Text())
273
274 // Skip comments and empty lines
275 if line == "" || strings.HasPrefix(line, "#") {
276 continue
277 }
278
279 fields := strings.Fields(line)
280 if len(fields) < 2 {
281 continue
282 }
283
284 key := strings.ToLower(fields[0])
285 value := fields[1]
286
287 // Expand ~ in paths
288 if strings.HasPrefix(value, "~/") {
289 value = filepath.Join(home, value[2:])
290 }
291
292 switch key {
293 case "host":
294 currentHost = value
295 if currentHost == host {
296 matchedHost = true
297 cfg.Host = host
298 } else {
299 matchedHost = false
300 }
301 case "hostname":
302 if matchedHost {
303 cfg.HostName = value
304 }
305 case "user":
306 if matchedHost {
307 cfg.User = value
308 }
309 case "port":
310 if matchedHost {
311 cfg.Port = value
312 }
313 case "identityfile":
314 if matchedHost {
315 cfg.IdentityFile = value
316 }
317 }
318 }
319
320 if err := scanner.Err(); err != nil {
321 return nil, err
322 }
323
324 if cfg.Host == "" {
325 return nil, fmt.Errorf("host %s not found in SSH config", host)
326 }
327
328 return cfg, nil
329}
330
331// sshAgent returns an auth method using SSH agent
332func sshAgent() (ssh.AuthMethod, error) {
333 socket := os.Getenv("SSH_AUTH_SOCK")
334 if socket == "" {
335 return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
336 }
337
338 conn, err := net.Dial("unix", socket)
339 if err != nil {
340 return nil, fmt.Errorf("failed to connect to SSH agent: %w", err)
341 }
342
343 agentClient := agent.NewClient(conn)
344 return ssh.PublicKeysCallback(agentClient.Signers), nil
345}
346
347// publicKeyFromFile returns an auth method from a specific private key file
348func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) {
349 key, err := os.ReadFile(keyPath)
350 if err != nil {
351 return nil, err
352 }
353
354 signer, err := ssh.ParsePrivateKey(key)
355 if err != nil {
356 return nil, err
357 }
358
359 return ssh.PublicKeys(signer), nil
360}
361
362// publicKeyFile returns an auth method using a private key file
363func publicKeyFile() (ssh.AuthMethod, error) {
364 home, err := os.UserHomeDir()
365 if err != nil {
366 return nil, err
367 }
368
369 // Try common key locations
370 keyPaths := []string{
371 filepath.Join(home, ".ssh", "id_rsa"),
372 filepath.Join(home, ".ssh", "id_ed25519"),
373 filepath.Join(home, ".ssh", "id_ecdsa"),
374 }
375
376 for _, keyPath := range keyPaths {
377 if _, err := os.Stat(keyPath); err == nil {
378 key, err := os.ReadFile(keyPath)
379 if err != nil {
380 continue
381 }
382
383 signer, err := ssh.ParsePrivateKey(key)
384 if err != nil {
385 continue
386 }
387
388 return ssh.PublicKeys(signer), nil
389 }
390 }
391
392 return nil, fmt.Errorf("no SSH private key found")
393}
diff --git a/internal/state/state.go b/internal/state/state.go
deleted file mode 100644
index 9b06179..0000000
--- a/internal/state/state.go
+++ /dev/null
@@ -1,106 +0,0 @@
1package state
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "regexp"
9)
10
11// State represents the local ship configuration
12type State struct {
13 DefaultHost string `json:"default_host,omitempty"`
14 Hosts map[string]*Host `json:"hosts"`
15}
16
17// Host represents configuration for a single VPS
18type Host struct {
19 BaseDomain string `json:"base_domain,omitempty"`
20 GitSetup bool `json:"git_setup,omitempty"`
21}
22
23var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
24
25// ValidateName checks that a name is safe for use in shell commands,
26// file paths, systemd units, and DNS labels.
27func ValidateName(name string) error {
28 if !validName.MatchString(name) {
29 return fmt.Errorf("invalid name %q: must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and be 1-63 characters", name)
30 }
31 return nil
32}
33
34// Load reads state from ~/.config/ship/state.json
35func Load() (*State, error) {
36 path := statePath()
37
38 if _, err := os.Stat(path); os.IsNotExist(err) {
39 return &State{
40 Hosts: make(map[string]*Host),
41 }, nil
42 }
43
44 data, err := os.ReadFile(path)
45 if err != nil {
46 return nil, fmt.Errorf("failed to read state file: %w", err)
47 }
48
49 var state State
50 if err := json.Unmarshal(data, &state); err != nil {
51 return nil, fmt.Errorf("failed to parse state file: %w", err)
52 }
53
54 if state.Hosts == nil {
55 state.Hosts = make(map[string]*Host)
56 }
57
58 return &state, nil
59}
60
61// Save writes state to ~/.config/ship/state.json
62func (s *State) Save() error {
63 path := statePath()
64
65 dir := filepath.Dir(path)
66 if err := os.MkdirAll(dir, 0755); err != nil {
67 return fmt.Errorf("failed to create config directory: %w", err)
68 }
69
70 data, err := json.MarshalIndent(s, "", " ")
71 if err != nil {
72 return fmt.Errorf("failed to marshal state: %w", err)
73 }
74
75 if err := os.WriteFile(path, data, 0600); err != nil {
76 return fmt.Errorf("failed to write state file: %w", err)
77 }
78
79 return nil
80}
81
82// GetHost returns the host config, creating it if it doesn't exist
83func (s *State) GetHost(host string) *Host {
84 if s.Hosts[host] == nil {
85 s.Hosts[host] = &Host{}
86 }
87 return s.Hosts[host]
88}
89
90// GetDefaultHost returns the default host, or empty string if not set
91func (s *State) GetDefaultHost() string {
92 return s.DefaultHost
93}
94
95// SetDefaultHost sets the default host
96func (s *State) SetDefaultHost(host string) {
97 s.DefaultHost = host
98}
99
100func statePath() string {
101 home, err := os.UserHomeDir()
102 if err != nil {
103 return ".ship-state.json"
104 }
105 return filepath.Join(home, ".config", "ship", "state.json")
106}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
deleted file mode 100644
index 2163f47..0000000
--- a/internal/templates/templates.go
+++ /dev/null
@@ -1,358 +0,0 @@
1package templates
2
3import (
4 "bytes"
5 "text/template"
6)
7
8var serviceTemplate = `[Unit]
9Description={{.Name}}
10After=network.target
11
12[Service]
13Type=simple
14User={{.User}}
15WorkingDirectory={{.WorkDir}}
16EnvironmentFile={{.EnvFile}}
17ExecStart={{.BinaryPath}} {{.Args}}
18Restart=always
19RestartSec=5s
20NoNewPrivileges=true
21PrivateTmp=true
22{{- if .Memory}}
23MemoryMax={{.Memory}}
24{{- end}}
25{{- if .CPU}}
26CPUQuota={{.CPU}}
27{{- end}}
28
29[Install]
30WantedBy=multi-user.target
31`
32
33var appCaddyTemplate = `{{.Domain}} {
34 reverse_proxy 127.0.0.1:{{.Port}}
35}
36`
37
38var staticCaddyTemplate = `{{.Domain}} {
39 root * {{.RootDir}}
40 file_server
41 encode gzip
42}
43`
44
45// SystemdService generates a systemd service unit file
46func SystemdService(data map[string]string) (string, error) {
47 tmpl, err := template.New("service").Parse(serviceTemplate)
48 if err != nil {
49 return "", err
50 }
51
52 var buf bytes.Buffer
53 if err := tmpl.Execute(&buf, data); err != nil {
54 return "", err
55 }
56
57 return buf.String(), nil
58}
59
60// AppCaddy generates a Caddy config for a Go app
61func AppCaddy(data map[string]string) (string, error) {
62 tmpl, err := template.New("caddy").Parse(appCaddyTemplate)
63 if err != nil {
64 return "", err
65 }
66
67 var buf bytes.Buffer
68 if err := tmpl.Execute(&buf, data); err != nil {
69 return "", err
70 }
71
72 return buf.String(), nil
73}
74
75// StaticCaddy generates a Caddy config for a static site
76func StaticCaddy(data map[string]string) (string, error) {
77 tmpl, err := template.New("caddy").Parse(staticCaddyTemplate)
78 if err != nil {
79 return "", err
80 }
81
82 var buf bytes.Buffer
83 if err := tmpl.Execute(&buf, data); err != nil {
84 return "", err
85 }
86
87 return buf.String(), nil
88}
89
90var postReceiveHookTemplate = `#!/bin/bash
91set -euo pipefail
92
93REPO=/srv/git/{{.Name}}.git
94SRC=/var/lib/{{.Name}}/src
95NAME={{.Name}}
96
97while read oldrev newrev refname; do
98 branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
99 [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
100done
101
102# Ensure checkout directory exists
103sudo /bin/mkdir -p "$SRC"
104sudo /bin/chown -R git:git "/var/lib/${NAME}"
105
106echo "==> Checking out code..."
107git --work-tree="$SRC" --git-dir="$REPO" checkout -f main
108
109cd "$SRC"
110
111# If no Dockerfile, nothing to deploy
112if [ ! -f Dockerfile ]; then
113 echo "No Dockerfile found, skipping deploy."
114 exit 0
115fi
116
117# Install deployment config from repo (using full paths for sudoers)
118if [ -f "$SRC/.ship/service" ]; then
119 echo "==> Installing systemd unit..."
120 sudo /bin/cp "$SRC/.ship/service" "/etc/systemd/system/${NAME}.service"
121 sudo systemctl daemon-reload
122fi
123if [ -f "$SRC/.ship/Caddyfile" ]; then
124 echo "==> Installing Caddy config..."
125 sudo /bin/cp "$SRC/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy"
126 sudo systemctl reload caddy
127fi
128
129# Ensure data directory exists
130sudo /bin/mkdir -p "/var/lib/${NAME}/data"
131sudo /bin/chown -R git:git "/var/lib/${NAME}/data"
132
133echo "==> Building Docker image..."
134docker build -t ${NAME}:latest .
135
136echo "==> Restarting service..."
137sudo systemctl restart ${NAME}
138
139echo "==> Deploy complete!"
140`
141
142var postReceiveHookStaticTemplate = `#!/bin/bash
143set -euo pipefail
144
145REPO=/srv/git/{{.Name}}.git
146WEBROOT=/var/www/{{.Name}}
147NAME={{.Name}}
148
149while read oldrev newrev refname; do
150 branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
151 [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
152done
153
154echo "==> Deploying static site..."
155git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main
156
157if [ -f "$WEBROOT/.ship/Caddyfile" ]; then
158 echo "==> Installing Caddy config..."
159 sudo /bin/cp "$WEBROOT/.ship/Caddyfile" "/etc/caddy/sites-enabled/${NAME}.caddy"
160 sudo systemctl reload caddy
161fi
162
163echo "==> Deploy complete!"
164`
165
166var codeCaddyTemplate = `{{.BaseDomain}} {
167 @goget query go-get=1
168 handle @goget {
169 root * /opt/ship/vanity
170 templates
171 rewrite * /index.html
172 file_server
173 }
174
175 @git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$"
176 handle @git {
177 reverse_proxy unix//run/fcgiwrap.socket {
178 transport fastcgi {
179 env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend
180 env GIT_PROJECT_ROOT /srv/git
181 env REQUEST_METHOD {method}
182 env QUERY_STRING {query}
183 env PATH_INFO {path}
184 }
185 }
186 }
187
188 @cgitassets path /cgit/*
189 handle @cgitassets {
190 root * /usr/share/cgit
191 uri strip_prefix /cgit
192 file_server
193 }
194
195 handle {
196 reverse_proxy unix//run/fcgiwrap.socket {
197 transport fastcgi {
198 env SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi
199 env QUERY_STRING {query}
200 env REQUEST_METHOD {method}
201 env PATH_INFO {path}
202 env HTTP_HOST {host}
203 env SERVER_NAME {host}
204 }
205 }
206 }
207}
208`
209
210var dockerServiceTemplate = `[Unit]
211Description={{.Name}}
212After=network.target docker.service
213Requires=docker.service
214
215[Service]
216Type=simple
217ExecStartPre=-/usr/bin/docker rm -f {{.Name}}
218ExecStart=/usr/bin/docker run --rm --name {{.Name}} \
219 -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \
220 --env-file /etc/ship/env/{{.Name}}.env \
221 -v /var/lib/{{.Name}}/data:/data \
222 {{.Name}}:latest
223ExecStop=/usr/bin/docker stop -t 10 {{.Name}}
224Restart=always
225RestartSec=5s
226
227[Install]
228WantedBy=multi-user.target
229`
230
231var defaultAppCaddyTemplate = `{{.Domain}} {
232 reverse_proxy 127.0.0.1:{{.Port}}
233}
234`
235
236var defaultStaticCaddyTemplate = `{{.Domain}} {
237 root * /var/www/{{.Name}}
238 file_server
239 encode gzip
240}
241`
242
243// PostReceiveHook generates a post-receive hook for git-app repos
244func PostReceiveHook(data map[string]string) (string, error) {
245 return renderTemplate("post-receive", postReceiveHookTemplate, data)
246}
247
248// PostReceiveHookStatic generates a post-receive hook for git-static repos
249func PostReceiveHookStatic(data map[string]string) (string, error) {
250 return renderTemplate("post-receive-static", postReceiveHookStaticTemplate, data)
251}
252
253// CodeCaddy generates the base domain Caddy config for vanity imports + git HTTP
254func CodeCaddy(data map[string]string) (string, error) {
255 return renderTemplate("code-caddy", codeCaddyTemplate, data)
256}
257
258var cgitrcTemplate = `virtual-root=/
259css=/cgit/cgit.css
260logo=/cgit/cgit.png
261header=/opt/ship/cgit-header.html
262scan-path=/srv/git/
263export-ok=git-daemon-export-ok
264enable-http-clone=0
265clone-url=https://{{.BaseDomain}}/$CGIT_REPO_URL
266root-title={{.BaseDomain}}
267root-desc=
268remove-suffix=.git
269`
270
271var cgitHeaderTemplate = `<style>
272body, table, td, th, div#cgit { background: #1a1a2e; color: #ccc; }
273a { color: #7aa2f7; }
274a:hover { color: #9ecbff; }
275table.list tr:hover td { background: #222244; }
276table.list td, table.list th { border-bottom: 1px solid #333; }
277th { background: #16213e; }
278td.commitgraph .column1 { color: #7aa2f7; }
279td.commitgraph .column2 { color: #9ece6a; }
280td.logheader { background: #16213e; }
281div#header { background: #16213e; border-bottom: 1px solid #333; }
282div#header .sub { color: #888; }
283table.tabs { border-bottom: 1px solid #333; }
284table.tabs td a { color: #ccc; }
285table.tabs td a.active { color: #fff; background: #1a1a2e; border: 1px solid #333; border-bottom: 1px solid #1a1a2e; }
286div.footer { color: #555; }
287div.footer a { color: #555; }
288div.diffstat-header { background: #16213e; }
289table.diffstat { border-bottom: 1px solid #333; }
290table.diffstat td.graph span.graph-moreremoved { background: #f7768e; }
291table.diffstat td.graph span.graph-moreadded { background: #9ece6a; }
292table.diffstat td.graph span.graph-removed { background: #f7768e; }
293table.diffstat td.graph span.graph-added { background: #9ece6a; }
294table.diff { background: #131320; border: 1px solid #333; }
295div.diff td { font-family: monospace; }
296div.head { color: #ccc; background: #16213e; padding: 2px 4px; }
297div.hunk { color: #7aa2f7; background: #1a1a3e; padding: 2px 4px; }
298div.add { color: #9ece6a; background: #1a2e1a; padding: 2px 4px; }
299div.del { color: #f7768e; background: #2e1a1a; padding: 2px 4px; }
300table.diff td.add { color: #9ece6a; background: #1a2e1a; }
301table.diff td.del { color: #f7768e; background: #2e1a1a; }
302table.diff td.hunk { color: #7aa2f7; background: #1a1a3e; }
303table.diff td { border: none; background: #1a1a2e; }
304table.blob td.lines { color: #ccc; }
305table.blob td.linenumbers { background: #16213e; }
306table.blob td.linenumbers a { color: #555; }
307table.blob td.linenumbers a:hover { color: #7aa2f7; }
308table.ssdiff td.add { color: #9ece6a; background: #1a2e1a; }
309table.ssdiff td.del { color: #f7768e; background: #2e1a1a; }
310table.ssdiff td { border-right: 1px solid #333; }
311table.ssdiff td.hunk { color: #7aa2f7; background: #1a1a3e; }
312table.ssdiff td.head { background: #16213e; border-bottom: 1px solid #333; }
313table.ssdiff td.foot { background: #16213e; border-top: 1px solid #333; }
314table.ssdiff td.lineno { background: #16213e; color: #555; }
315pre { color: #ccc; }
316input, textarea, select { background: #222; color: #ccc; border: 1px solid #444; }
317img#logo { display: none; }
318</style>
319`
320
321// CgitRC generates the /etc/cgitrc config file
322func CgitRC(data map[string]string) (string, error) {
323 return renderTemplate("cgitrc", cgitrcTemplate, data)
324}
325
326// CgitHeader generates the cgit header HTML file (dark theme)
327func CgitHeader() string {
328 return cgitHeaderTemplate
329}
330
331// DockerService generates a systemd unit for a Docker-based app
332func DockerService(data map[string]string) (string, error) {
333 return renderTemplate("docker-service", dockerServiceTemplate, data)
334}
335
336// DefaultAppCaddy generates a default Caddyfile for a git-app
337func DefaultAppCaddy(data map[string]string) (string, error) {
338 return renderTemplate("default-app-caddy", defaultAppCaddyTemplate, data)
339}
340
341// DefaultStaticCaddy generates a default Caddyfile for a git-static site
342func DefaultStaticCaddy(data map[string]string) (string, error) {
343 return renderTemplate("default-static-caddy", defaultStaticCaddyTemplate, data)
344}
345
346func renderTemplate(name, tmplStr string, data map[string]string) (string, error) {
347 tmpl, err := template.New(name).Parse(tmplStr)
348 if err != nil {
349 return "", err
350 }
351
352 var buf bytes.Buffer
353 if err := tmpl.Execute(&buf, data); err != nil {
354 return "", err
355 }
356
357 return buf.String(), nil
358}
diff --git a/templates/app.caddy.tmpl b/templates/app.caddy.tmpl
deleted file mode 100644
index 505d1d9..0000000
--- a/templates/app.caddy.tmpl
+++ /dev/null
@@ -1,3 +0,0 @@
1{{.Domain}} {
2 reverse_proxy 127.0.0.1:{{.Port}}
3}
diff --git a/templates/service.tmpl b/templates/service.tmpl
deleted file mode 100644
index 87389f0..0000000
--- a/templates/service.tmpl
+++ /dev/null
@@ -1,17 +0,0 @@
1[Unit]
2Description={{.Name}}
3After=network.target
4
5[Service]
6Type=simple
7User={{.User}}
8WorkingDirectory={{.WorkDir}}
9EnvironmentFile={{.EnvFile}}
10ExecStart={{.BinaryPath}} --port={{.Port}}
11Restart=always
12RestartSec=5s
13NoNewPrivileges=true
14PrivateTmp=true
15
16[Install]
17WantedBy=multi-user.target
diff --git a/templates/static.caddy.tmpl b/templates/static.caddy.tmpl
deleted file mode 100644
index d04f6b0..0000000
--- a/templates/static.caddy.tmpl
+++ /dev/null
@@ -1,5 +0,0 @@
1{{.Domain}} {
2 root * {{.RootDir}}
3 file_server
4 encode gzip
5}
diff --git a/test/example-website/about.html b/test/example-website/about.html
deleted file mode 100644
index 93cba92..0000000
--- a/test/example-website/about.html
+++ /dev/null
@@ -1,42 +0,0 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>About - Deploy Test</title>
7 <link rel="stylesheet" href="style.css">
8</head>
9<body>
10 <header>
11 <nav>
12 <h1>Deploy Test</h1>
13 <ul>
14 <li><a href="index.html">Home</a></li>
15 <li><a href="about.html">About</a></li>
16 </ul>
17 </nav>
18 </header>
19
20 <main>
21 <section class="about">
22 <h2>About This Site</h2>
23 <p>This is a test website created to demonstrate the deploy tool's static site deployment capabilities.</p>
24
25 <h3>Features</h3>
26 <ul>
27 <li>Simple HTML/CSS structure</li>
28 <li>Multiple pages for testing navigation</li>
29 <li>Responsive design</li>
30 <li>Clean and minimal styling</li>
31 </ul>
32
33 <h3>Deployment Command</h3>
34 <pre><code>./deploy deploy --host peerfile --static --dir ./test/example-website --domain example.com</code></pre>
35 </section>
36 </main>
37
38 <footer>
39 <p>&copy; 2025 Deploy Test. Built for testing purposes.</p>
40 </footer>
41</body>
42</html>
diff --git a/test/example-website/index.html b/test/example-website/index.html
deleted file mode 100644
index 735ae73..0000000
--- a/test/example-website/index.html
+++ /dev/null
@@ -1,46 +0,0 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Example Website - Deploy Test</title>
7 <link rel="stylesheet" href="style.css">
8</head>
9<body>
10 <header>
11 <nav>
12 <h1>Deploy Test</h1>
13 <ul>
14 <li><a href="index.html">Home</a></li>
15 <li><a href="about.html">About</a></li>
16 </ul>
17 </nav>
18 </header>
19
20 <main>
21 <section class="hero">
22 <h2>Welcome to the Example Website</h2>
23 <p>This is a simple static website for testing the deploy tool.</p>
24 </section>
25
26 <section class="features">
27 <div class="feature">
28 <h3>Fast Deployment</h3>
29 <p>Deploy your static sites in seconds with a single command.</p>
30 </div>
31 <div class="feature">
32 <h3>HTTPS Enabled</h3>
33 <p>Automatic SSL certificates with Caddy.</p>
34 </div>
35 <div class="feature">
36 <h3>Simple Management</h3>
37 <p>Easy-to-use CLI for managing your deployments.</p>
38 </div>
39 </section>
40 </main>
41
42 <footer>
43 <p>&copy; 2025 Deploy Test. Built for testing purposes.</p>
44 </footer>
45</body>
46</html>
diff --git a/test/example-website/style.css b/test/example-website/style.css
deleted file mode 100644
index da7fd1c..0000000
--- a/test/example-website/style.css
+++ /dev/null
@@ -1,156 +0,0 @@
1* {
2 margin: 0;
3 padding: 0;
4 box-sizing: border-box;
5}
6
7body {
8 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9 line-height: 1.6;
10 color: #333;
11 background: #f5f5f5;
12}
13
14header {
15 background: #2c3e50;
16 color: white;
17 padding: 1rem 0;
18 box-shadow: 0 2px 5px rgba(0,0,0,0.1);
19}
20
21nav {
22 max-width: 1200px;
23 margin: 0 auto;
24 padding: 0 2rem;
25 display: flex;
26 justify-content: space-between;
27 align-items: center;
28}
29
30nav h1 {
31 font-size: 1.5rem;
32}
33
34nav ul {
35 list-style: none;
36 display: flex;
37 gap: 2rem;
38}
39
40nav a {
41 color: white;
42 text-decoration: none;
43 transition: opacity 0.3s;
44}
45
46nav a:hover {
47 opacity: 0.8;
48}
49
50main {
51 max-width: 1200px;
52 margin: 2rem auto;
53 padding: 0 2rem;
54}
55
56.hero {
57 background: white;
58 padding: 3rem;
59 border-radius: 8px;
60 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
61 text-align: center;
62 margin-bottom: 2rem;
63}
64
65.hero h2 {
66 font-size: 2.5rem;
67 margin-bottom: 1rem;
68 color: #2c3e50;
69}
70
71.hero p {
72 font-size: 1.2rem;
73 color: #666;
74}
75
76.features {
77 display: grid;
78 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
79 gap: 2rem;
80 margin: 2rem 0;
81}
82
83.feature {
84 background: white;
85 padding: 2rem;
86 border-radius: 8px;
87 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
88}
89
90.feature h3 {
91 color: #2c3e50;
92 margin-bottom: 0.5rem;
93}
94
95.feature p {
96 color: #666;
97}
98
99.about {
100 background: white;
101 padding: 3rem;
102 border-radius: 8px;
103 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
104}
105
106.about h2 {
107 color: #2c3e50;
108 margin-bottom: 1rem;
109}
110
111.about h3 {
112 color: #34495e;
113 margin-top: 2rem;
114 margin-bottom: 0.5rem;
115}
116
117.about ul {
118 margin-left: 2rem;
119 margin-bottom: 1rem;
120}
121
122.about pre {
123 background: #f5f5f5;
124 padding: 1rem;
125 border-radius: 4px;
126 overflow-x: auto;
127 margin-top: 1rem;
128}
129
130.about code {
131 font-family: 'Courier New', monospace;
132 font-size: 0.9rem;
133}
134
135footer {
136 background: #2c3e50;
137 color: white;
138 text-align: center;
139 padding: 2rem;
140 margin-top: 4rem;
141}
142
143@media (max-width: 768px) {
144 nav {
145 flex-direction: column;
146 gap: 1rem;
147 }
148
149 .hero h2 {
150 font-size: 2rem;
151 }
152
153 .features {
154 grid-template-columns: 1fr;
155 }
156}
diff --git a/website/index.html b/website/index.html
deleted file mode 100644
index e7e6d7a..0000000
--- a/website/index.html
+++ /dev/null
@@ -1,216 +0,0 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>ship — deploy to your VPS</title>
7 <style>
8 :root {
9 --bg: #f5f2eb;
10 --fg: #2c2c2c;
11 --muted: #6b6b6b;
12 --accent: #1a1a1a;
13 --border: #d4d0c8;
14 }
15
16 * { margin: 0; padding: 0; box-sizing: border-box; }
17
18 body {
19 font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
20 font-size: 16px;
21 line-height: 1.6;
22 background: var(--bg);
23 color: var(--fg);
24 }
25
26 main {
27 max-width: 640px;
28 margin: 0 auto;
29 padding: 4rem 1.5rem;
30 }
31
32 h1 {
33 font-size: 1.5rem;
34 font-weight: 600;
35 margin-bottom: 0.5rem;
36 }
37
38 .tagline {
39 color: var(--muted);
40 margin-bottom: 3rem;
41 }
42
43 section {
44 margin-bottom: 3rem;
45 }
46
47 h2 {
48 font-size: 0.875rem;
49 font-weight: 600;
50 text-transform: uppercase;
51 letter-spacing: 0.05em;
52 color: var(--muted);
53 margin-bottom: 1rem;
54 }
55
56 p {
57 margin-bottom: 1rem;
58 }
59
60 pre {
61 background: var(--accent);
62 color: var(--bg);
63 padding: 1rem 1.25rem;
64 overflow-x: auto;
65 font-size: 0.875rem;
66 line-height: 1.7;
67 margin-bottom: 1rem;
68 }
69
70 code {
71 font-family: inherit;
72 }
73
74 .comment { color: #888; }
75 .output { color: #a0a0a0; }
76
77 ul {
78 list-style: none;
79 margin-bottom: 1rem;
80 }
81
82 li {
83 padding-left: 1.25rem;
84 position: relative;
85 margin-bottom: 0.5rem;
86 }
87
88 li::before {
89 content: "—";
90 position: absolute;
91 left: 0;
92 color: var(--muted);
93 }
94
95 a {
96 color: var(--fg);
97 text-decoration: underline;
98 text-underline-offset: 2px;
99 }
100
101 a:hover {
102 color: var(--muted);
103 }
104
105 .features {
106 display: grid;
107 gap: 1.5rem;
108 margin-bottom: 1rem;
109 }
110
111 .feature {
112 border: 1px solid var(--border);
113 padding: 1rem 1.25rem;
114 }
115
116 .feature strong {
117 display: block;
118 margin-bottom: 0.25rem;
119 }
120
121 .feature span {
122 color: var(--muted);
123 font-size: 0.875rem;
124 }
125
126 footer {
127 border-top: 1px solid var(--border);
128 padding-top: 2rem;
129 color: var(--muted);
130 font-size: 0.875rem;
131 }
132 </style>
133</head>
134<body>
135 <main>
136 <header>
137 <h1>ship</h1>
138 <p class="tagline">Deploy code to your VPS. Get a URL back.</p>
139 </header>
140
141 <section>
142 <h2>What it does</h2>
143 <p>Point ship at a directory or binary. It figures out what you're deploying, uploads it, configures HTTPS, and gives you a URL. No containers required. No YAML. No config files.</p>
144 <p>Built for AI agents. JSON output by default, predictable behavior, and an <a href="https://github.com/bdw/ship/tree/main/skill">agent skill</a> so your assistant can deploy code without hand-holding.</p>
145 </section>
146
147 <section>
148 <h2>Usage</h2>
149 <pre><span class="comment"># static site</span>
150ship ./dist
151<span class="output">→ https://ship-a1b2c3.example.com</span>
152
153<span class="comment"># with a name</span>
154ship ./dist --name docs
155<span class="output">→ https://docs.example.com</span>
156
157<span class="comment"># binary with health check</span>
158ship ./myapp --name api --health /healthz
159<span class="output">→ https://api.example.com</span>
160
161<span class="comment"># temporary preview (auto-deletes)</span>
162ship ./preview --ttl 1h
163<span class="output">→ https://ship-x7y8z9.example.com (expires in 1h)</span>
164
165<span class="comment"># custom domain</span>
166ship ./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>
195ship 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 &lt;path&gt;</code> — deploy</li>
203 <li><code>ship list</code> — show all deployments</li>
204 <li><code>ship status &lt;name&gt;</code> — check a deployment</li>
205 <li><code>ship logs &lt;name&gt;</code> — view logs</li>
206 <li><code>ship remove &lt;name&gt;</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>