The codebase that powers boop.cat boop.cat
at main 221 lines 5.1 kB view raw
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}