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