The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5package deploy
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15)
16
17func validateBuildCommand(cmd string) error {
18 cmd = strings.TrimSpace(cmd)
19 if cmd == "" {
20 return nil
21 }
22
23 dangerousMap := []string{
24 "&", "|", ";", ">", "<", "`", "$(",
25 }
26 for _, char := range dangerousMap {
27 if strings.Contains(cmd, char) {
28 return fmt.Errorf("command contains forbidden character: %s", char)
29 }
30 }
31
32 allowedPrefixes := []string{
33 "npm ", "yarn ", "pnpm ", "bun ", "npx ", "node ",
34 }
35 isAllowed := false
36 for _, p := range allowedPrefixes {
37 if strings.HasPrefix(cmd, p) {
38 isAllowed = true
39 break
40 }
41 }
42 if !isAllowed {
43 return errors.New("command must start with npm, yarn, pnpm, bun, npx, or node")
44 }
45
46 forbiddenKeywords := []string{
47 " start", " dev", " serve", " preview", " watch",
48 }
49 for _, kw := range forbiddenKeywords {
50 if strings.Contains(cmd, kw) {
51 return fmt.Errorf("command looks like a runtime server (contains '%s'), only build commands are allowed", strings.TrimSpace(kw))
52 }
53 }
54
55 return nil
56}
57
58func fileExists(path string) bool {
59 _, err := os.Stat(path)
60 return err == nil
61}
62
63type BuildSystem struct {
64 RootDir string
65 Env []string
66 Logger func(string)
67}
68
69func (b *BuildSystem) DetectPackageManager() string {
70 if fileExists(filepath.Join(b.RootDir, "bun.lockb")) || fileExists(filepath.Join(b.RootDir, "bun.lock")) {
71 return "bun"
72 }
73 if fileExists(filepath.Join(b.RootDir, "pnpm-lock.yaml")) {
74 return "pnpm"
75 }
76 if fileExists(filepath.Join(b.RootDir, "yarn.lock")) {
77 return "yarn"
78 }
79 if fileExists(filepath.Join(b.RootDir, "package-lock.json")) {
80 return "npm"
81 }
82
83 return "npm"
84}
85
86func (b *BuildSystem) InstallArgs(pm string) []string {
87
88 switch pm {
89 case "npm":
90 if fileExists(filepath.Join(b.RootDir, "package-lock.json")) {
91 return []string{"ci", "--include=dev"}
92 }
93 return []string{"install", "--include=dev"}
94 case "yarn":
95 if fileExists(filepath.Join(b.RootDir, "yarn.lock")) {
96 return []string{"install", "--frozen-lockfile", "--production=false"}
97 }
98 return []string{"install", "--production=false"}
99 case "pnpm":
100 return []string{"install", "--frozen-lockfile", "--production=false"}
101 case "bun":
102 return []string{"install"}
103 }
104 return []string{"install"}
105}
106
107func (b *BuildSystem) BuildArgs(pm string) []string {
108 switch pm {
109 case "yarn", "pnpm":
110 return []string{"build"}
111 case "deno":
112 return []string{"task", "build"}
113 default:
114 return []string{"run", "build"}
115 }
116}
117
118func (b *BuildSystem) RunCommand(ctx context.Context, name string, args ...string) error {
119 cmd := exec.CommandContext(ctx, name, args...)
120 cmd.Dir = b.RootDir
121 cmd.Env = append(os.Environ(), b.Env...)
122
123 stdout, _ := cmd.StdoutPipe()
124 stderr, _ := cmd.StderrPipe()
125
126 if err := cmd.Start(); err != nil {
127 return err
128 }
129
130 go func() {
131 buf := make([]byte, 1024)
132 for {
133 n, err := stdout.Read(buf)
134 if n > 0 && b.Logger != nil {
135 b.Logger(string(buf[:n]))
136 }
137 if err != nil {
138 break
139 }
140 }
141 }()
142
143 go func() {
144 buf := make([]byte, 1024)
145 for {
146 n, err := stderr.Read(buf)
147 if n > 0 && b.Logger != nil {
148 b.Logger(string(buf[:n]))
149 }
150 if err != nil {
151 break
152 }
153 }
154 }()
155
156 return cmd.Wait()
157}
158
159func (b *BuildSystem) Build(ctx context.Context, customCommand string) (string, error) {
160
161 pm := b.DetectPackageManager()
162
163 if fileExists(filepath.Join(b.RootDir, "package.json")) {
164 installArgs := b.InstallArgs(pm)
165
166 if b.Logger != nil {
167 b.Logger(fmt.Sprintf("Installing dependencies with %s %v...\n", pm, installArgs))
168 }
169
170 if err := b.RunCommand(ctx, pm, installArgs...); err != nil {
171 return "", fmt.Errorf("install failed: %w", err)
172 }
173 }
174
175 if customCommand != "" {
176 if err := validateBuildCommand(customCommand); err != nil {
177 if b.Logger != nil {
178 b.Logger(fmt.Sprintf("Invalid build command: %v\n", err))
179 }
180 return "", fmt.Errorf("invalid build command: %w", err)
181 }
182 if b.Logger != nil {
183 b.Logger(fmt.Sprintf("Running custom build command: %s\n", customCommand))
184 }
185 if err := b.RunCommand(ctx, "sh", "-c", customCommand); err != nil {
186 return "", fmt.Errorf("build failed: %w", err)
187 }
188 } else if fileExists(filepath.Join(b.RootDir, "package.json")) {
189
190 buildArgs := b.BuildArgs(pm)
191 if b.Logger != nil {
192 b.Logger(fmt.Sprintf("Building with %s %v...\n", pm, buildArgs))
193 }
194 if err := b.RunCommand(ctx, pm, buildArgs...); err != nil {
195 return "", fmt.Errorf("build failed: %w", err)
196 }
197 }
198
199 return b.DetectOutputDirectory()
200}
201
202func (b *BuildSystem) DetectOutputDirectory() (string, error) {
203 candidates := []string{"dist", "build", "public", ".svelte-kit/output", "out", "_site"}
204 for _, c := range candidates {
205 path := filepath.Join(b.RootDir, c)
206
207 if fileExists(path) {
208
209 return c, nil
210 }
211 }
212
213 if fileExists(filepath.Join(b.RootDir, "index.html")) {
214 if b.Logger != nil {
215 b.Logger("No build directory detected, but index.html found. Using root directory.")
216 }
217 return ".", nil
218 }
219
220 return "", fmt.Errorf("could not detect build output directory")
221}