|
1
|
package ergo |
|
2
|
|
|
3
|
import ( |
|
4
|
"archive/tar" |
|
5
|
"compress/gzip" |
|
6
|
"encoding/json" |
|
7
|
"fmt" |
|
8
|
"io" |
|
9
|
"net/http" |
|
10
|
"os" |
|
11
|
"path/filepath" |
|
12
|
"runtime" |
|
13
|
) |
|
14
|
|
|
15
|
const ergoGitHubAPI = "https://api.github.com/repos/ergochat/ergo/releases/latest" |
|
16
|
|
|
17
|
// EnsureBinary checks that the ergo binary exists at binaryPath. If it does |
|
18
|
// not, it downloads the latest release from GitHub into destDir and returns |
|
19
|
// the path to the installed binary. |
|
20
|
// |
|
21
|
// binaryPath is the configured path (may be just "ergo" meaning look in PATH). |
|
22
|
// destDir is where to install if not found. |
|
23
|
func EnsureBinary(binaryPath, destDir string) (string, error) { |
|
24
|
// If it's an absolute path or the caller set a specific path, check it first. |
|
25
|
if filepath.IsAbs(binaryPath) { |
|
26
|
if _, err := os.Stat(binaryPath); err == nil { |
|
27
|
return binaryPath, nil |
|
28
|
} |
|
29
|
} |
|
30
|
|
|
31
|
// Check if ergo is already in our data dir. |
|
32
|
localPath := filepath.Join(destDir, "ergo") |
|
33
|
if _, err := os.Stat(localPath); err == nil { |
|
34
|
return localPath, nil |
|
35
|
} |
|
36
|
|
|
37
|
// Download from GitHub releases. |
|
38
|
version, downloadURL, err := latestReleaseURL() |
|
39
|
if err != nil { |
|
40
|
return "", fmt.Errorf("ergo: fetch latest release info: %w", err) |
|
41
|
} |
|
42
|
|
|
43
|
fmt.Fprintf(os.Stderr, "ergo binary not found — downloading %s...\n", version) |
|
44
|
|
|
45
|
if err := os.MkdirAll(destDir, 0o700); err != nil { |
|
46
|
return "", fmt.Errorf("ergo: create data dir: %w", err) |
|
47
|
} |
|
48
|
|
|
49
|
if err := downloadAndExtract(downloadURL, destDir); err != nil { |
|
50
|
return "", fmt.Errorf("ergo: download: %w", err) |
|
51
|
} |
|
52
|
|
|
53
|
fmt.Fprintf(os.Stderr, "ergo %s installed to %s\n", version, localPath) |
|
54
|
return localPath, nil |
|
55
|
} |
|
56
|
|
|
57
|
// latestReleaseURL queries GitHub for the latest ergo release and returns |
|
58
|
// the version string and the download URL for the current OS/arch. |
|
59
|
func latestReleaseURL() (string, string, error) { |
|
60
|
resp, err := http.Get(ergoGitHubAPI) //nolint:gosec // known GitHub API URL |
|
61
|
if err != nil { |
|
62
|
return "", "", err |
|
63
|
} |
|
64
|
defer resp.Body.Close() |
|
65
|
|
|
66
|
var release struct { |
|
67
|
TagName string `json:"tag_name"` |
|
68
|
Assets []struct { |
|
69
|
Name string `json:"name"` |
|
70
|
BrowserDownloadURL string `json:"browser_download_url"` |
|
71
|
} `json:"assets"` |
|
72
|
} |
|
73
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { |
|
74
|
return "", "", err |
|
75
|
} |
|
76
|
|
|
77
|
suffix := platformSuffix() |
|
78
|
for _, asset := range release.Assets { |
|
79
|
if matchesPlatform(asset.Name, suffix) { |
|
80
|
return release.TagName, asset.BrowserDownloadURL, nil |
|
81
|
} |
|
82
|
} |
|
83
|
|
|
84
|
return "", "", fmt.Errorf("no release asset found for %s/%s (tag %s)", runtime.GOOS, runtime.GOARCH, release.TagName) |
|
85
|
} |
|
86
|
|
|
87
|
// platformSuffix returns the OS-arch suffix used in ergo release filenames. |
|
88
|
// Ergo uses "macos" instead of "darwin" and "x86_64" instead of "amd64". |
|
89
|
func platformSuffix() string { |
|
90
|
goos := runtime.GOOS |
|
91
|
if goos == "darwin" { |
|
92
|
goos = "macos" |
|
93
|
} |
|
94
|
arch := runtime.GOARCH |
|
95
|
if arch == "amd64" { |
|
96
|
arch = "x86_64" |
|
97
|
} |
|
98
|
return goos + "-" + arch |
|
99
|
} |
|
100
|
|
|
101
|
func matchesPlatform(name, suffix string) bool { |
|
102
|
// Ergo assets look like: ergo-v2.14.0-linux-x86_64.tar.gz |
|
103
|
return len(name) > 0 && |
|
104
|
filepath.Ext(name) == ".gz" && |
|
105
|
contains(name, suffix) && |
|
106
|
contains(name, ".tar.") |
|
107
|
} |
|
108
|
|
|
109
|
func contains(s, sub string) bool { |
|
110
|
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) |
|
111
|
} |
|
112
|
|
|
113
|
func containsStr(s, sub string) bool { |
|
114
|
for i := 0; i <= len(s)-len(sub); i++ { |
|
115
|
if s[i:i+len(sub)] == sub { |
|
116
|
return true |
|
117
|
} |
|
118
|
} |
|
119
|
return false |
|
120
|
} |
|
121
|
|
|
122
|
// downloadAndExtract downloads a .tar.gz and extracts the "ergo" binary into destDir. |
|
123
|
func downloadAndExtract(url, destDir string) error { |
|
124
|
resp, err := http.Get(url) //nolint:gosec // URL from GitHub API |
|
125
|
if err != nil { |
|
126
|
return err |
|
127
|
} |
|
128
|
defer resp.Body.Close() |
|
129
|
|
|
130
|
gz, err := gzip.NewReader(resp.Body) |
|
131
|
if err != nil { |
|
132
|
return fmt.Errorf("gzip: %w", err) |
|
133
|
} |
|
134
|
defer gz.Close() |
|
135
|
|
|
136
|
tr := tar.NewReader(gz) |
|
137
|
for { |
|
138
|
hdr, err := tr.Next() |
|
139
|
if err == io.EOF { |
|
140
|
break |
|
141
|
} |
|
142
|
if err != nil { |
|
143
|
return fmt.Errorf("tar: %w", err) |
|
144
|
} |
|
145
|
// Extract only the "ergo" binary (may be at root or in a subdirectory). |
|
146
|
if filepath.Base(hdr.Name) != "ergo" || hdr.Typeflag != tar.TypeReg { |
|
147
|
continue |
|
148
|
} |
|
149
|
|
|
150
|
dest := filepath.Join(destDir, "ergo") |
|
151
|
f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) |
|
152
|
if err != nil { |
|
153
|
return err |
|
154
|
} |
|
155
|
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // size bounded by release binary |
|
156
|
f.Close() |
|
157
|
return err |
|
158
|
} |
|
159
|
return f.Close() |
|
160
|
} |
|
161
|
|
|
162
|
return fmt.Errorf("ergo binary not found in archive") |
|
163
|
} |
|
164
|
|