summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/ship/deploy.go56
-rw-r--r--cmd/ship/deploy_cmd.go138
-rw-r--r--cmd/ship/host/init.go131
-rw-r--r--cmd/ship/init.go247
-rw-r--r--cmd/ship/list.go2
-rw-r--r--cmd/ship/logs.go2
-rw-r--r--cmd/ship/remove.go27
-rw-r--r--cmd/ship/restart.go2
-rw-r--r--cmd/ship/root.go2
-rw-r--r--cmd/ship/status.go2
-rw-r--r--internal/state/state.go8
-rw-r--r--internal/templates/templates.go168
12 files changed, 750 insertions, 35 deletions
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go
index 24eed1e..9ac754c 100644
--- a/cmd/ship/deploy.go
+++ b/cmd/ship/deploy.go
@@ -415,7 +415,7 @@ func updateAppConfig(st *state.State, opts DeployOptions) error {
415 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) 415 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name)
416 } 416 }
417 417
418 if existingApp.Type != "app" { 418 if existingApp.Type != "app" && existingApp.Type != "git-app" {
419 return fmt.Errorf("%s is a static site, not an app", opts.Name) 419 return fmt.Errorf("%s is a static site, not an app", opts.Name)
420 } 420 }
421 421
@@ -441,33 +441,37 @@ func updateAppConfig(st *state.State, opts DeployOptions) error {
441 return fmt.Errorf("error creating env file: %w", err) 441 return fmt.Errorf("error creating env file: %w", err)
442 } 442 }
443 443
444 // Regenerate systemd unit 444 // For git-app, the systemd unit comes from .ship/service in the repo,
445 fmt.Println("-> Updating systemd service...") 445 // so we only update the env file and restart.
446 workDir := fmt.Sprintf("/var/lib/%s", opts.Name) 446 if existingApp.Type != "git-app" {
447 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) 447 // Regenerate systemd unit
448 serviceContent, err := templates.SystemdService(map[string]string{ 448 fmt.Println("-> Updating systemd service...")
449 "Name": opts.Name, 449 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
450 "User": opts.Name, 450 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
451 "WorkDir": workDir, 451 serviceContent, err := templates.SystemdService(map[string]string{
452 "BinaryPath": binaryDest, 452 "Name": opts.Name,
453 "Port": strconv.Itoa(existingApp.Port), 453 "User": opts.Name,
454 "EnvFile": envFilePath, 454 "WorkDir": workDir,
455 "Args": opts.Args, 455 "BinaryPath": binaryDest,
456 "Memory": opts.Memory, 456 "Port": strconv.Itoa(existingApp.Port),
457 "CPU": opts.CPU, 457 "EnvFile": envFilePath,
458 }) 458 "Args": opts.Args,
459 if err != nil { 459 "Memory": opts.Memory,
460 return fmt.Errorf("error generating systemd unit: %w", err) 460 "CPU": opts.CPU,
461 } 461 })
462 if err != nil {
463 return fmt.Errorf("error generating systemd unit: %w", err)
464 }
462 465
463 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) 466 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
464 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { 467 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
465 return fmt.Errorf("error creating systemd unit: %w", err) 468 return fmt.Errorf("error creating systemd unit: %w", err)
466 } 469 }
467 470
468 fmt.Println("-> Reloading systemd...") 471 fmt.Println("-> Reloading systemd...")
469 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { 472 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
470 return fmt.Errorf("error reloading systemd: %w", err) 473 return fmt.Errorf("error reloading systemd: %w", err)
474 }
471 } 475 }
472 476
473 fmt.Println("-> Restarting service...") 477 fmt.Println("-> Restarting service...")
diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go
new file mode 100644
index 0000000..ca5c54d
--- /dev/null
+++ b/cmd/ship/deploy_cmd.go
@@ -0,0 +1,138 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var deployGitCmd = &cobra.Command{
12 Use: "deploy <name>",
13 Short: "Manually rebuild and deploy a git-deployed app",
14 Long: `Trigger a manual rebuild from the latest code in the git repo.
15
16This runs the same steps as the post-receive hook: checkout code,
17install .ship/ configs, docker build (for apps), and restart.
18
19Examples:
20 ship deploy myapp`,
21 Args: cobra.ExactArgs(1),
22 RunE: runDeployGit,
23}
24
25func runDeployGit(cmd *cobra.Command, args []string) error {
26 name := args[0]
27
28 st, err := state.Load()
29 if err != nil {
30 return fmt.Errorf("error loading state: %w", err)
31 }
32
33 host := hostFlag
34 if host == "" {
35 host = st.GetDefaultHost()
36 }
37 if host == "" {
38 return fmt.Errorf("--host is required")
39 }
40
41 app, err := st.GetApp(host, name)
42 if err != nil {
43 return err
44 }
45
46 if app.Type != "git-app" && app.Type != "git-static" {
47 return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type)
48 }
49
50 fmt.Printf("Deploying %s...\n", name)
51
52 client, err := ssh.Connect(host)
53 if err != nil {
54 return fmt.Errorf("error connecting to VPS: %w", err)
55 }
56 defer client.Close()
57
58 if app.Type == "git-app" {
59 if err := deployGitApp(client, name); err != nil {
60 return err
61 }
62 } else {
63 if err := deployGitStatic(client, name); err != nil {
64 return err
65 }
66 }
67
68 fmt.Println("\nDeploy complete!")
69 return nil
70}
71
72func deployGitApp(client *ssh.Client, name string) error {
73 repo := fmt.Sprintf("/srv/git/%s.git", name)
74 src := fmt.Sprintf("/var/lib/%s/src", name)
75
76 fmt.Println("-> Checking out code...")
77 if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil {
78 return fmt.Errorf("error checking out code: %w", err)
79 }
80
81 // Install deployment config from repo
82 serviceSrc := fmt.Sprintf("%s/.ship/service", src)
83 serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name)
84 fmt.Println("-> Installing systemd unit...")
85 if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil {
86 fmt.Printf(" Warning: no .ship/service found, skipping\n")
87 } else {
88 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
89 return fmt.Errorf("error reloading systemd: %w", err)
90 }
91 }
92
93 caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src)
94 caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
95 fmt.Println("-> Installing Caddy config...")
96 if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil {
97 fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n")
98 } else {
99 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
100 return fmt.Errorf("error reloading Caddy: %w", err)
101 }
102 }
103
104 fmt.Println("-> Building Docker image...")
105 if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil {
106 return fmt.Errorf("error building Docker image: %w", err)
107 }
108
109 fmt.Println("-> Restarting service...")
110 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
111 return fmt.Errorf("error restarting service: %w", err)
112 }
113
114 return nil
115}
116
117func deployGitStatic(client *ssh.Client, name string) error {
118 repo := fmt.Sprintf("/srv/git/%s.git", name)
119 webroot := fmt.Sprintf("/var/www/%s", name)
120
121 fmt.Println("-> Deploying static site...")
122 if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil {
123 return fmt.Errorf("error checking out code: %w", err)
124 }
125
126 caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot)
127 caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
128 fmt.Println("-> Installing Caddy config...")
129 if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil {
130 fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n")
131 } else {
132 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
133 return fmt.Errorf("error reloading Caddy: %w", err)
134 }
135 }
136
137 return nil
138}
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go
index 27f67af..d7143ff 100644
--- a/cmd/ship/host/init.go
+++ b/cmd/ship/host/init.go
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bdw/ship/internal/ssh" 7 "github.com/bdw/ship/internal/ssh"
8 "github.com/bdw/ship/internal/state" 8 "github.com/bdw/ship/internal/state"
9 "github.com/bdw/ship/internal/templates"
9 "github.com/spf13/cobra" 10 "github.com/spf13/cobra"
10) 11)
11 12
@@ -105,6 +106,14 @@ import /etc/caddy/sites-enabled/*
105 hostState.BaseDomain = baseDomain 106 hostState.BaseDomain = baseDomain
106 fmt.Printf(" Base domain: %s\n", baseDomain) 107 fmt.Printf(" Base domain: %s\n", baseDomain)
107 } 108 }
109
110 // Git-centric deployment setup (gated on base domain)
111 if baseDomain != "" {
112 if err := setupGitDeploy(client, baseDomain, hostState); err != nil {
113 return err
114 }
115 }
116
108 if st.GetDefaultHost() == "" { 117 if st.GetDefaultHost() == "" {
109 st.SetDefaultHost(host) 118 st.SetDefaultHost(host)
110 fmt.Printf(" Set %s as default host\n", host) 119 fmt.Printf(" Set %s as default host\n", host)
@@ -119,6 +128,128 @@ import /etc/caddy/sites-enabled/*
119 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") 128 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n")
120 fmt.Println(" 2. Deploy a static site:") 129 fmt.Println(" 2. Deploy a static site:")
121 fmt.Printf(" ship --static --dir ./dist --domain example.com\n") 130 fmt.Printf(" ship --static --dir ./dist --domain example.com\n")
131 if baseDomain != "" {
132 fmt.Println(" 3. Initialize a git-deployed app:")
133 fmt.Printf(" ship init myapp\n")
134 }
135 return nil
136}
137
138func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error {
139 fmt.Println("-> Installing Docker...")
140 dockerCommands := []string{
141 "apt-get install -y ca-certificates curl gnupg",
142 "install -m 0755 -d /etc/apt/keyrings",
143 "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
144 "chmod a+r /etc/apt/keyrings/docker.asc",
145 `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" | tee /etc/apt/sources.list.d/docker.list > /dev/null`,
146 "apt-get update",
147 "apt-get install -y docker-ce docker-ce-cli containerd.io",
148 }
149 for _, cmd := range dockerCommands {
150 if _, err := client.RunSudo(cmd); err != nil {
151 return fmt.Errorf("error installing Docker: %w", err)
152 }
153 }
154 fmt.Println(" Docker installed")
155
156 fmt.Println("-> Installing git and fcgiwrap...")
157 if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil {
158 return fmt.Errorf("error installing git/fcgiwrap: %w", err)
159 }
160 fmt.Println(" git and fcgiwrap installed")
161
162 fmt.Println("-> Creating git user...")
163 // Create git user (ignore error if already exists)
164 client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git")
165 if _, err := client.RunSudo("usermod -aG docker git"); err != nil {
166 return fmt.Errorf("error adding git user to docker group: %w", err)
167 }
168 fmt.Println(" git user created")
169
170 fmt.Println("-> Copying SSH keys to git user...")
171 copyKeysCommands := []string{
172 "mkdir -p /home/git/.ssh",
173 "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys",
174 "chown -R git:git /home/git/.ssh",
175 "chmod 700 /home/git/.ssh",
176 "chmod 600 /home/git/.ssh/authorized_keys",
177 }
178 for _, cmd := range copyKeysCommands {
179 if _, err := client.RunSudo(cmd); err != nil {
180 return fmt.Errorf("error copying SSH keys: %w", err)
181 }
182 }
183 fmt.Println(" SSH keys copied")
184
185 fmt.Println("-> Creating /srv/git...")
186 if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil {
187 return fmt.Errorf("error creating /srv/git: %w", err)
188 }
189 if _, err := client.RunSudo("chown git:git /srv/git"); err != nil {
190 return fmt.Errorf("error setting /srv/git ownership: %w", err)
191 }
192 fmt.Println(" /srv/git created")
193
194 fmt.Println("-> Writing sudoers for git user...")
195 sudoersContent := `git ALL=(ALL) NOPASSWD: /bin/systemctl restart *, /bin/systemctl daemon-reload, /bin/systemctl reload caddy, /bin/systemctl enable *, /bin/cp * /etc/systemd/system/*, /bin/cp * /etc/caddy/sites-enabled/*, /bin/mkdir -p /var/lib/*, /bin/mkdir -p /var/www/*, /bin/chown *
196`
197 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
198 return fmt.Errorf("error writing sudoers: %w", err)
199 }
200 if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil {
201 return fmt.Errorf("error setting sudoers permissions: %w", err)
202 }
203 fmt.Println(" sudoers configured")
204
205 fmt.Println("-> Writing vanity import template...")
206 vanityHTML := `<!DOCTYPE html>
207<html><head>
208{{$path := trimPrefix "/" .Req.URL.Path}}
209{{$parts := splitList "/" $path}}
210{{$module := first $parts}}
211<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
212</head>
213<body>go get {{.Host}}/{{$module}}</body>
214</html>
215`
216 if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil {
217 return fmt.Errorf("error creating vanity directory: %w", err)
218 }
219 if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil {
220 return fmt.Errorf("error writing vanity template: %w", err)
221 }
222 fmt.Println(" vanity template written")
223
224 fmt.Println("-> Writing base domain Caddy config...")
225 codeCaddyContent, err := templates.CodeCaddy(map[string]string{
226 "BaseDomain": baseDomain,
227 })
228 if err != nil {
229 return fmt.Errorf("error generating code caddy config: %w", err)
230 }
231 if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil {
232 return fmt.Errorf("error writing code caddy config: %w", err)
233 }
234 fmt.Println(" base domain Caddy config written")
235
236 fmt.Println("-> Starting Docker and fcgiwrap...")
237 if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil {
238 return fmt.Errorf("error enabling services: %w", err)
239 }
240 if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil {
241 return fmt.Errorf("error starting services: %w", err)
242 }
243 fmt.Println(" Docker and fcgiwrap started")
244
245 fmt.Println("-> Reloading Caddy...")
246 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
247 return fmt.Errorf("error reloading Caddy: %w", err)
248 }
249 fmt.Println(" Caddy reloaded")
250
251 hostState.GitSetup = true
252 fmt.Println(" Git deployment setup complete")
122 return nil 253 return nil
123} 254}
124 255
diff --git a/cmd/ship/init.go b/cmd/ship/init.go
new file mode 100644
index 0000000..167347d
--- /dev/null
+++ b/cmd/ship/init.go
@@ -0,0 +1,247 @@
1package main
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strconv"
8
9 "github.com/bdw/ship/internal/ssh"
10 "github.com/bdw/ship/internal/state"
11 "github.com/bdw/ship/internal/templates"
12 "github.com/spf13/cobra"
13)
14
15var initCmd = &cobra.Command{
16 Use: "init <name>",
17 Short: "Initialize a git-deployed project",
18 Long: `Create a bare git repo on the VPS and generate local .ship/ config files.
19
20Pushing to the remote triggers an automatic docker build and deploy (for apps)
21or a static file checkout (for static sites).
22
23Examples:
24 # Initialize an app (Docker-based)
25 ship init myapp
26
27 # Initialize with a custom domain
28 ship init myapp --domain custom.example.com
29
30 # Initialize a static site
31 ship init mysite --static`,
32 Args: cobra.ExactArgs(1),
33 RunE: runInit,
34}
35
36func init() {
37 initCmd.Flags().Bool("static", false, "Initialize as static site")
38 initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)")
39}
40
41func runInit(cmd *cobra.Command, args []string) error {
42 name := args[0]
43 static, _ := cmd.Flags().GetBool("static")
44 domain, _ := cmd.Flags().GetString("domain")
45
46 st, err := state.Load()
47 if err != nil {
48 return fmt.Errorf("error loading state: %w", err)
49 }
50
51 host := hostFlag
52 if host == "" {
53 host = st.GetDefaultHost()
54 }
55 if host == "" {
56 return fmt.Errorf("--host is required")
57 }
58
59 hostState := st.GetHost(host)
60 if !hostState.GitSetup {
61 return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host)
62 }
63
64 // Check if app already exists
65 if _, err := st.GetApp(host, name); err == nil {
66 return fmt.Errorf("app %s already exists", name)
67 }
68
69 // Resolve domain
70 if domain == "" && hostState.BaseDomain != "" {
71 domain = name + "." + hostState.BaseDomain
72 }
73 if domain == "" {
74 return fmt.Errorf("--domain required (or configure base domain)")
75 }
76
77 appType := "git-app"
78 if static {
79 appType = "git-static"
80 }
81
82 // Allocate port for apps
83 port := 0
84 if !static {
85 port = st.AllocatePort(host)
86 }
87
88 fmt.Printf("Initializing %s: %s\n", appType, name)
89
90 client, err := ssh.Connect(host)
91 if err != nil {
92 return fmt.Errorf("error connecting to VPS: %w", err)
93 }
94 defer client.Close()
95
96 // Create bare repo
97 fmt.Println("-> Creating bare git repo...")
98 repo := fmt.Sprintf("/srv/git/%s.git", name)
99 if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare %s", repo)); err != nil {
100 return fmt.Errorf("error creating bare repo: %w", err)
101 }
102
103 if static {
104 // Create web root
105 fmt.Println("-> Creating web root...")
106 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil {
107 return fmt.Errorf("error creating web root: %w", err)
108 }
109 if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil {
110 return fmt.Errorf("error setting web root ownership: %w", err)
111 }
112
113 // Write post-receive hook
114 fmt.Println("-> Writing post-receive hook...")
115 hookContent, err := templates.PostReceiveHookStatic(map[string]string{
116 "Name": name,
117 })
118 if err != nil {
119 return fmt.Errorf("error generating hook: %w", err)
120 }
121 hookPath := fmt.Sprintf("%s/hooks/post-receive", repo)
122 if err := client.WriteSudoFile(hookPath, hookContent); err != nil {
123 return fmt.Errorf("error writing hook: %w", err)
124 }
125 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil {
126 return fmt.Errorf("error making hook executable: %w", err)
127 }
128 if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil {
129 return fmt.Errorf("error setting hook ownership: %w", err)
130 }
131 } else {
132 // Create directories for app
133 fmt.Println("-> Creating app directories...")
134 dirs := []string{
135 fmt.Sprintf("/var/lib/%s/data", name),
136 fmt.Sprintf("/var/lib/%s/src", name),
137 }
138 for _, dir := range dirs {
139 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil {
140 return fmt.Errorf("error creating directory %s: %w", dir, err)
141 }
142 }
143 if _, err := client.RunSudo(fmt.Sprintf("chown -R git:git /var/lib/%s", name)); err != nil {
144 return fmt.Errorf("error setting directory ownership: %w", err)
145 }
146
147 // Create env file
148 fmt.Println("-> Creating environment file...")
149 envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port)
150 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
151 if err := client.WriteSudoFile(envPath, envContent); err != nil {
152 return fmt.Errorf("error creating env file: %w", err)
153 }
154
155 // Write post-receive hook
156 fmt.Println("-> Writing post-receive hook...")
157 hookContent, err := templates.PostReceiveHook(map[string]string{
158 "Name": name,
159 })
160 if err != nil {
161 return fmt.Errorf("error generating hook: %w", err)
162 }
163 hookPath := fmt.Sprintf("%s/hooks/post-receive", repo)
164 if err := client.WriteSudoFile(hookPath, hookContent); err != nil {
165 return fmt.Errorf("error writing hook: %w", err)
166 }
167 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil {
168 return fmt.Errorf("error making hook executable: %w", err)
169 }
170 if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil {
171 return fmt.Errorf("error setting hook ownership: %w", err)
172 }
173 }
174
175 // Save state
176 st.AddApp(host, name, &state.App{
177 Type: appType,
178 Domain: domain,
179 Port: port,
180 Repo: repo,
181 })
182 if err := st.Save(); err != nil {
183 return fmt.Errorf("error saving state: %w", err)
184 }
185
186 // Generate local .ship/ files
187 fmt.Println("-> Generating local .ship/ config...")
188 if err := os.MkdirAll(".ship", 0755); err != nil {
189 return fmt.Errorf("error creating .ship directory: %w", err)
190 }
191
192 if static {
193 caddyContent, err := templates.DefaultStaticCaddy(map[string]string{
194 "Domain": domain,
195 "Name": name,
196 })
197 if err != nil {
198 return fmt.Errorf("error generating Caddyfile: %w", err)
199 }
200 if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil {
201 return fmt.Errorf("error writing Caddyfile: %w", err)
202 }
203 } else {
204 caddyContent, err := templates.DefaultAppCaddy(map[string]string{
205 "Domain": domain,
206 "Port": strconv.Itoa(port),
207 })
208 if err != nil {
209 return fmt.Errorf("error generating Caddyfile: %w", err)
210 }
211 if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil {
212 return fmt.Errorf("error writing Caddyfile: %w", err)
213 }
214
215 serviceContent, err := templates.DockerService(map[string]string{
216 "Name": name,
217 "Port": strconv.Itoa(port),
218 })
219 if err != nil {
220 return fmt.Errorf("error generating service file: %w", err)
221 }
222 if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil {
223 return fmt.Errorf("error writing service file: %w", err)
224 }
225 }
226
227 // Resolve SSH hostname for git remote URL
228 sshHost := host
229
230 fmt.Printf("\nProject initialized: %s\n", name)
231 fmt.Println("\nGenerated:")
232 fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)")
233 if !static {
234 fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)")
235 }
236 fmt.Println("\nNext steps:")
237 if static {
238 fmt.Println(" git add .ship/")
239 } else {
240 fmt.Println(" git add .ship/ Dockerfile")
241 }
242 fmt.Println(" git commit -m \"initial deploy\"")
243 fmt.Printf(" git remote add ship git@%s:%s\n", sshHost, repo)
244 fmt.Println(" git push ship main")
245
246 return nil
247}
diff --git a/cmd/ship/list.go b/cmd/ship/list.go
index 404ca68..a10b2ca 100644
--- a/cmd/ship/list.go
+++ b/cmd/ship/list.go
@@ -40,7 +40,7 @@ func runList(cmd *cobra.Command, args []string) error {
40 fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") 40 fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT")
41 for name, app := range apps { 41 for name, app := range apps {
42 port := "" 42 port := ""
43 if app.Type == "app" { 43 if app.Type == "app" || app.Type == "git-app" {
44 port = fmt.Sprintf(":%d", app.Port) 44 port = fmt.Sprintf(":%d", app.Port)
45 } 45 }
46 domain := app.Domain 46 domain := app.Domain
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go
index 1932c18..4d85fe1 100644
--- a/cmd/ship/logs.go
+++ b/cmd/ship/logs.go
@@ -44,7 +44,7 @@ func runLogs(cmd *cobra.Command, args []string) error {
44 return err 44 return err
45 } 45 }
46 46
47 if app.Type != "app" { 47 if app.Type != "app" && app.Type != "git-app" {
48 return fmt.Errorf("logs are only available for apps, not static sites") 48 return fmt.Errorf("logs are only available for apps, not static sites")
49 } 49 }
50 50
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go
index 922eb8f..8380e87 100644
--- a/cmd/ship/remove.go
+++ b/cmd/ship/remove.go
@@ -46,7 +46,8 @@ func runRemove(cmd *cobra.Command, args []string) error {
46 } 46 }
47 defer client.Close() 47 defer client.Close()
48 48
49 if app.Type == "app" { 49 switch app.Type {
50 case "app":
50 fmt.Println("-> Stopping service...") 51 fmt.Println("-> Stopping service...")
51 client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) 52 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
52 client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) 53 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
@@ -58,7 +59,29 @@ func runRemove(cmd *cobra.Command, args []string) error {
58 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) 59 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
59 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) 60 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
60 client.RunSudo(fmt.Sprintf("userdel %s", name)) 61 client.RunSudo(fmt.Sprintf("userdel %s", name))
61 } else { 62
63 case "git-app":
64 fmt.Println("-> Stopping service...")
65 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
66 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
67
68 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
69 client.RunSudo("systemctl daemon-reload")
70
71 fmt.Println("-> Removing Docker image...")
72 client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name))
73
74 fmt.Println("-> Removing files...")
75 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
76 client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name))
77 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
78
79 case "git-static":
80 fmt.Println("-> Removing files...")
81 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
82 client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name))
83
84 default: // "static"
62 fmt.Println("-> Removing files...") 85 fmt.Println("-> Removing files...")
63 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) 86 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
64 } 87 }
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go
index 2c74c62..404624a 100644
--- a/cmd/ship/restart.go
+++ b/cmd/ship/restart.go
@@ -37,7 +37,7 @@ func runRestart(cmd *cobra.Command, args []string) error {
37 return err 37 return err
38 } 38 }
39 39
40 if app.Type != "app" { 40 if app.Type != "app" && app.Type != "git-app" {
41 return fmt.Errorf("restart is only available for apps, not static sites") 41 return fmt.Errorf("restart is only available for apps, not static sites")
42 } 42 }
43 43
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
index 837fd4c..93280f5 100644
--- a/cmd/ship/root.go
+++ b/cmd/ship/root.go
@@ -88,6 +88,8 @@ func init() {
88 rootCmd.AddCommand(statusCmd) 88 rootCmd.AddCommand(statusCmd)
89 rootCmd.AddCommand(restartCmd) 89 rootCmd.AddCommand(restartCmd)
90 rootCmd.AddCommand(removeCmd) 90 rootCmd.AddCommand(removeCmd)
91 rootCmd.AddCommand(initCmd)
92 rootCmd.AddCommand(deployGitCmd)
91 rootCmd.AddCommand(env.Cmd) 93 rootCmd.AddCommand(env.Cmd)
92 rootCmd.AddCommand(host.Cmd) 94 rootCmd.AddCommand(host.Cmd)
93 rootCmd.AddCommand(uiCmd) 95 rootCmd.AddCommand(uiCmd)
diff --git a/cmd/ship/status.go b/cmd/ship/status.go
index 03c548b..536ec8c 100644
--- a/cmd/ship/status.go
+++ b/cmd/ship/status.go
@@ -37,7 +37,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
37 return err 37 return err
38 } 38 }
39 39
40 if app.Type != "app" { 40 if app.Type != "app" && app.Type != "git-app" {
41 return fmt.Errorf("status is only available for apps, not static sites") 41 return fmt.Errorf("status is only available for apps, not static sites")
42 } 42 }
43 43
diff --git a/internal/state/state.go b/internal/state/state.go
index 38657cb..324fd34 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -17,15 +17,17 @@ type State struct {
17type Host struct { 17type Host struct {
18 NextPort int `json:"next_port"` 18 NextPort int `json:"next_port"`
19 BaseDomain string `json:"base_domain,omitempty"` 19 BaseDomain string `json:"base_domain,omitempty"`
20 GitSetup bool `json:"git_setup,omitempty"`
20 Apps map[string]*App `json:"apps"` 21 Apps map[string]*App `json:"apps"`
21} 22}
22 23
23// App represents a deployed application or static site 24// App represents a deployed application or static site
24type App struct { 25type App struct {
25 Type string `json:"type"` // "app" or "static" 26 Type string `json:"type"` // "app", "static", "git-app", or "git-static"
26 Domain string `json:"domain"` 27 Domain string `json:"domain"`
27 Port int `json:"port,omitempty"` // only for type="app" 28 Port int `json:"port,omitempty"` // only for type="app" or "git-app"
28 Env map[string]string `json:"env,omitempty"` // only for type="app" 29 Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git"
30 Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app"
29 Args string `json:"args,omitempty"` // only for type="app" 31 Args string `json:"args,omitempty"` // only for type="app"
30 Files []string `json:"files,omitempty"` // only for type="app" 32 Files []string `json:"files,omitempty"` // only for type="app"
31 Memory string `json:"memory,omitempty"` // only for type="app" 33 Memory string `json:"memory,omitempty"` // only for type="app"
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
index ce1cbe5..8615117 100644
--- a/internal/templates/templates.go
+++ b/internal/templates/templates.go
@@ -86,3 +86,171 @@ func StaticCaddy(data map[string]string) (string, error) {
86 86
87 return buf.String(), nil 87 return buf.String(), nil
88} 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
102echo "==> Checking out code..."
103git --work-tree="$SRC" --git-dir="$REPO" checkout -f main
104
105cd "$SRC"
106
107# Install deployment config from repo
108if [ -f .ship/service ]; then
109 echo "==> Installing systemd unit..."
110 sudo cp .ship/service /etc/systemd/system/${NAME}.service
111 sudo systemctl daemon-reload
112fi
113if [ -f .ship/Caddyfile ]; then
114 echo "==> Installing Caddy config..."
115 sudo cp .ship/Caddyfile /etc/caddy/sites-enabled/${NAME}.caddy
116 sudo systemctl reload caddy
117fi
118
119echo "==> Building Docker image..."
120docker build -t ${NAME}:latest .
121
122echo "==> Restarting service..."
123sudo systemctl restart ${NAME}
124
125echo "==> Deploy complete!"
126`
127
128var postReceiveHookStaticTemplate = `#!/bin/bash
129set -euo pipefail
130
131REPO=/srv/git/{{.Name}}.git
132WEBROOT=/var/www/{{.Name}}
133NAME={{.Name}}
134
135while read oldrev newrev refname; do
136 branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
137 [ "$branch" = "main" ] || { echo "Pushed to $branch, skipping deploy."; exit 0; }
138done
139
140echo "==> Deploying static site..."
141git --work-tree="$WEBROOT" --git-dir="$REPO" checkout -f main
142
143if [ -f "$WEBROOT/.ship/Caddyfile" ]; then
144 echo "==> Installing Caddy config..."
145 sudo cp "$WEBROOT/.ship/Caddyfile" /etc/caddy/sites-enabled/${NAME}.caddy
146 sudo systemctl reload caddy
147fi
148
149echo "==> Deploy complete!"
150`
151
152var codeCaddyTemplate = `{{.BaseDomain}} {
153 @goget query go-get=1
154 handle @goget {
155 root * /opt/ship/vanity
156 templates
157 rewrite * /index.html
158 file_server
159 }
160
161 @git path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack|git-receive-pack)$"
162 handle @git {
163 reverse_proxy unix//run/fcgiwrap.socket {
164 transport fastcgi {
165 env SCRIPT_FILENAME /usr/lib/git-core/git-http-backend
166 env GIT_PROJECT_ROOT /srv/git
167 env GIT_HTTP_EXPORT_ALL 1
168 env REQUEST_METHOD {method}
169 env QUERY_STRING {query}
170 env PATH_INFO {path}
171 }
172 }
173 }
174
175 handle {
176 respond "not found" 404
177 }
178}
179`
180
181var dockerServiceTemplate = `[Unit]
182Description={{.Name}}
183After=network.target docker.service
184Requires=docker.service
185
186[Service]
187Type=simple
188ExecStartPre=-/usr/bin/docker rm -f {{.Name}}
189ExecStart=/usr/bin/docker run --rm --name {{.Name}} \
190 -p 127.0.0.1:{{.Port}}:{{.Port}} \
191 --env-file /etc/ship/env/{{.Name}}.env \
192 -v /var/lib/{{.Name}}/data:/data \
193 {{.Name}}:latest
194ExecStop=/usr/bin/docker stop -t 10 {{.Name}}
195Restart=always
196RestartSec=5s
197
198[Install]
199WantedBy=multi-user.target
200`
201
202var defaultAppCaddyTemplate = `{{.Domain}} {
203 reverse_proxy 127.0.0.1:{{.Port}}
204}
205`
206
207var defaultStaticCaddyTemplate = `{{.Domain}} {
208 root * /var/www/{{.Name}}
209 file_server
210 encode gzip
211}
212`
213
214// PostReceiveHook generates a post-receive hook for git-app repos
215func PostReceiveHook(data map[string]string) (string, error) {
216 return renderTemplate("post-receive", postReceiveHookTemplate, data)
217}
218
219// PostReceiveHookStatic generates a post-receive hook for git-static repos
220func PostReceiveHookStatic(data map[string]string) (string, error) {
221 return renderTemplate("post-receive-static", postReceiveHookStaticTemplate, data)
222}
223
224// CodeCaddy generates the base domain Caddy config for vanity imports + git HTTP
225func CodeCaddy(data map[string]string) (string, error) {
226 return renderTemplate("code-caddy", codeCaddyTemplate, data)
227}
228
229// DockerService generates a systemd unit for a Docker-based app
230func DockerService(data map[string]string) (string, error) {
231 return renderTemplate("docker-service", dockerServiceTemplate, data)
232}
233
234// DefaultAppCaddy generates a default Caddyfile for a git-app
235func DefaultAppCaddy(data map[string]string) (string, error) {
236 return renderTemplate("default-app-caddy", defaultAppCaddyTemplate, data)
237}
238
239// DefaultStaticCaddy generates a default Caddyfile for a git-static site
240func DefaultStaticCaddy(data map[string]string) (string, error) {
241 return renderTemplate("default-static-caddy", defaultStaticCaddyTemplate, data)
242}
243
244func renderTemplate(name, tmplStr string, data map[string]string) (string, error) {
245 tmpl, err := template.New(name).Parse(tmplStr)
246 if err != nil {
247 return "", err
248 }
249
250 var buf bytes.Buffer
251 if err := tmpl.Execute(&buf, data); err != nil {
252 return "", err
253 }
254
255 return buf.String(), nil
256}