Skip to content

Commit 51e47ec

Browse files
authored
Adding support for enola upgrade (#75)
* Adding support for enola upgrade * Updating README.md, fixing lint
1 parent e74637a commit 51e47ec

4 files changed

Lines changed: 465 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@ export PATH="$HOME/.local/bin:$PATH"
151151

152152
Binaries are published for Linux, macOS (amd64/arm64), and Windows (amd64). You can also download a specific build from the [Releases page](https://github.com/enola-labs/enola/releases), or [build from source](#build-from-source).
153153

154+
### Upgrade
155+
156+
Once installed, update to the latest release in place:
157+
158+
```bash
159+
enola upgrade
160+
```
161+
162+
This downloads the newest build for your platform, verifies its checksum, and replaces the running binary. If enola is installed somewhere your user can't write, re-run with elevated permissions or re-run the install script above.
163+
164+
Because your agent launches enola as a long-lived MCP server process, an upgrade only takes effect once that process restarts — reconnect the MCP server so it picks up the new binary:
165+
166+
- **Claude Code** — restart the session, or re-register with `claude mcp remove enola && claude mcp add enola enola`.
167+
- **Cursor** — toggle the enola server off and back on in **Settings → MCP** (or reload the window).
168+
- **GitHub Copilot (VS Code)** — restart the server from the `.vscode/mcp.json` editor (the **Restart** CodeLens above the server entry), or reload the window.
169+
154170
### Configuration (optional)
155171

156172
**enola needs no config file.** Every setting has a built-in default, so out of the box it indexes the current repo with all extractors enabled and writes to `.enola/`. A config file (`mcp-arch.yaml`) only *overrides* those defaults — it never adds capability you'd otherwise lack. When enola can't find one it simply prints `warning: …, using defaults` and carries on.

cmd/enola/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99

1010
"github.com/enola-labs/enola/internal/config"
11+
"github.com/enola-labs/enola/internal/upgrade"
1112
"github.com/enola-labs/enola/internal/version"
1213
"github.com/enola-labs/enola/pkg/bootstrap"
1314
"github.com/enola-labs/enola/pkg/explain"
@@ -19,6 +20,13 @@ func main() {
1920

2021
ctx := context.Background()
2122

23+
if len(os.Args) > 1 && os.Args[1] == "upgrade" {
24+
if err := upgrade.Run(ctx, version.Version); err != nil {
25+
log.Fatalf("upgrade failed: %v", err)
26+
}
27+
os.Exit(0)
28+
}
29+
2230
generateMode := false
2331
explainMode := false
2432
cfgPath := "mcp-arch.yaml"

internal/upgrade/upgrade.go

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Package upgrade implements the `enola upgrade` self-update command. It
2+
// resolves the latest GitHub release for enola, downloads the artifact for the
3+
// current platform, verifies its checksum, and atomically replaces the running
4+
// binary. It mirrors the download/verify/install contract of install.sh.
5+
package upgrade
6+
7+
import (
8+
"archive/tar"
9+
"bytes"
10+
"compress/gzip"
11+
"context"
12+
"crypto/sha256"
13+
"encoding/hex"
14+
"encoding/json"
15+
"errors"
16+
"fmt"
17+
"io"
18+
"net/http"
19+
"os"
20+
"path/filepath"
21+
"runtime"
22+
"strings"
23+
)
24+
25+
// Overridable base URLs (production defaults). Tests point these at an
26+
// httptest server to exercise Run without touching the network.
27+
var (
28+
// apiBase is the GitHub REST API host for the releases/latest lookup.
29+
apiBase = "https://api.github.com"
30+
// downloadBase is the host serving release assets.
31+
downloadBase = "https://github.com"
32+
)
33+
34+
const (
35+
repoSlug = "enola-labs/enola"
36+
// maxDownload bounds the artifact download to guard against absurd sizes.
37+
maxDownload = 512 << 20 // 512 MiB
38+
)
39+
40+
// supportedPlatforms lists the GOOS/GOARCH combinations the release workflow
41+
// builds. Anything else has no downloadable artifact.
42+
var supportedPlatforms = map[string]bool{
43+
"linux/amd64": true,
44+
"linux/arm64": true,
45+
"darwin/amd64": true,
46+
"darwin/arm64": true,
47+
"windows/amd64": true,
48+
}
49+
50+
// Run performs a self-update to the latest release. current is the installed
51+
// version (version.Version); a value of "dev" is always treated as out of date.
52+
func Run(ctx context.Context, current string) error {
53+
current = strings.TrimPrefix(current, "v")
54+
55+
latest, err := latestVersion(ctx)
56+
if err != nil {
57+
return fmt.Errorf("resolving latest version: %w", err)
58+
}
59+
60+
if current != "dev" && current == latest {
61+
fmt.Fprintf(os.Stderr, "enola is already up to date (v%s)\n", current)
62+
return nil
63+
}
64+
65+
names, err := assetNames(latest, runtime.GOOS, runtime.GOARCH)
66+
if err != nil {
67+
return err
68+
}
69+
70+
fmt.Fprintf(os.Stderr, "==> Downloading enola v%s for %s/%s ...\n", latest, runtime.GOOS, runtime.GOARCH)
71+
72+
base := fmt.Sprintf("%s/%s/releases/download/v%s", downloadBase, repoSlug, latest)
73+
tarball, err := download(ctx, base+"/"+names.tarball)
74+
if err != nil {
75+
return fmt.Errorf("downloading %s: %w", names.tarball, err)
76+
}
77+
sumFile, err := download(ctx, base+"/"+names.checksum)
78+
if err != nil {
79+
return fmt.Errorf("downloading %s: %w", names.checksum, err)
80+
}
81+
82+
fmt.Fprintln(os.Stderr, "==> Verifying checksum ...")
83+
if err := verifyChecksum(tarball, sumFile); err != nil {
84+
return err
85+
}
86+
87+
fmt.Fprintln(os.Stderr, "==> Extracting ...")
88+
binary, err := extractBinary(tarball, names.innerBinary)
89+
if err != nil {
90+
return err
91+
}
92+
93+
fmt.Fprintln(os.Stderr, "==> Installing ...")
94+
if err := replaceExecutable(binary); err != nil {
95+
return err
96+
}
97+
98+
fmt.Fprintf(os.Stderr, "Upgraded enola v%s -> v%s\n", current, latest)
99+
return nil
100+
}
101+
102+
// latestVersion queries the GitHub API for the latest release tag and returns
103+
// it without a leading "v".
104+
func latestVersion(ctx context.Context) (string, error) {
105+
url := fmt.Sprintf("%s/repos/%s/releases/latest", apiBase, repoSlug)
106+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
107+
if err != nil {
108+
return "", err
109+
}
110+
req.Header.Set("Accept", "application/vnd.github+json")
111+
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
112+
req.Header.Set("Authorization", "Bearer "+tok)
113+
}
114+
115+
resp, err := http.DefaultClient.Do(req)
116+
if err != nil {
117+
return "", err
118+
}
119+
defer func() { _ = resp.Body.Close() }()
120+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
121+
return "", fmt.Errorf("GitHub API returned %s", resp.Status)
122+
}
123+
124+
var payload struct {
125+
TagName string `json:"tag_name"`
126+
}
127+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
128+
return "", err
129+
}
130+
if payload.TagName == "" {
131+
return "", errors.New("no tag_name in latest release")
132+
}
133+
return strings.TrimPrefix(payload.TagName, "v"), nil
134+
}
135+
136+
type assets struct {
137+
tarball string
138+
checksum string
139+
innerBinary string
140+
}
141+
142+
// assetNames derives the release artifact names for a version and platform,
143+
// matching the naming produced by .github/workflows/release.yml.
144+
func assetNames(version, goos, goarch string) (assets, error) {
145+
if !supportedPlatforms[goos+"/"+goarch] {
146+
return assets{}, fmt.Errorf("no prebuilt release for %s/%s; install manually from https://github.com/%s/releases", goos, goarch, repoSlug)
147+
}
148+
base := fmt.Sprintf("enola-%s-%s-%s", version, goos, goarch)
149+
inner := base
150+
if goos == "windows" {
151+
inner += ".exe"
152+
}
153+
return assets{
154+
tarball: base + ".tar.gz",
155+
checksum: base + ".sha256",
156+
innerBinary: inner,
157+
}, nil
158+
}
159+
160+
// download fetches url and returns the full response body.
161+
func download(ctx context.Context, url string) ([]byte, error) {
162+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
163+
if err != nil {
164+
return nil, err
165+
}
166+
resp, err := http.DefaultClient.Do(req)
167+
if err != nil {
168+
return nil, err
169+
}
170+
defer func() { _ = resp.Body.Close() }()
171+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
172+
return nil, fmt.Errorf("HTTP %s", resp.Status)
173+
}
174+
return io.ReadAll(io.LimitReader(resp.Body, maxDownload))
175+
}
176+
177+
// verifyChecksum compares the sha256 of tarball against the digest recorded in
178+
// a sha256sum-format file (`<hex> <filename>`).
179+
func verifyChecksum(tarball, sumFile []byte) error {
180+
fields := strings.Fields(string(sumFile))
181+
if len(fields) == 0 {
182+
return errors.New("empty checksum file")
183+
}
184+
want := strings.ToLower(fields[0])
185+
186+
sum := sha256.Sum256(tarball)
187+
got := hex.EncodeToString(sum[:])
188+
if got != want {
189+
return fmt.Errorf("checksum mismatch: expected %s, got %s", want, got)
190+
}
191+
return nil
192+
}
193+
194+
// extractBinary reads a gzipped tarball and returns the contents of the entry
195+
// named want.
196+
func extractBinary(tarball []byte, want string) ([]byte, error) {
197+
gz, err := gzip.NewReader(bytes.NewReader(tarball))
198+
if err != nil {
199+
return nil, fmt.Errorf("opening gzip: %w", err)
200+
}
201+
defer func() { _ = gz.Close() }()
202+
203+
tr := tar.NewReader(gz)
204+
for {
205+
hdr, err := tr.Next()
206+
if errors.Is(err, io.EOF) {
207+
break
208+
}
209+
if err != nil {
210+
return nil, fmt.Errorf("reading tar: %w", err)
211+
}
212+
if filepath.Base(hdr.Name) == want {
213+
data, err := io.ReadAll(io.LimitReader(tr, maxDownload))
214+
if err != nil {
215+
return nil, fmt.Errorf("extracting %s: %w", want, err)
216+
}
217+
return data, nil
218+
}
219+
}
220+
return nil, fmt.Errorf("binary %q not found in archive", want)
221+
}
222+
223+
// replaceExecutable writes the new binary next to the current executable and
224+
// atomically swaps it into place.
225+
func replaceExecutable(binary []byte) error {
226+
exe, err := os.Executable()
227+
if err != nil {
228+
return fmt.Errorf("locating current executable: %w", err)
229+
}
230+
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
231+
exe = resolved
232+
}
233+
234+
dir := filepath.Dir(exe)
235+
tmp, err := os.CreateTemp(dir, ".enola-upgrade-*")
236+
if err != nil {
237+
return installPermError(dir, err)
238+
}
239+
tmpPath := tmp.Name()
240+
cleanup := true
241+
defer func() {
242+
if cleanup {
243+
_ = os.Remove(tmpPath)
244+
}
245+
}()
246+
247+
if _, err := tmp.Write(binary); err != nil {
248+
_ = tmp.Close()
249+
return err
250+
}
251+
if err := tmp.Close(); err != nil {
252+
return err
253+
}
254+
if err := os.Chmod(tmpPath, 0o755); err != nil {
255+
return err
256+
}
257+
258+
if runtime.GOOS == "windows" {
259+
// A running .exe cannot be overwritten; move it aside first.
260+
old := exe + ".old"
261+
_ = os.Remove(old) // best-effort cleanup of a prior upgrade
262+
if err := os.Rename(exe, old); err != nil {
263+
return installPermError(dir, err)
264+
}
265+
if err := os.Rename(tmpPath, exe); err != nil {
266+
_ = os.Rename(old, exe) // roll back
267+
return installPermError(dir, err)
268+
}
269+
cleanup = false
270+
_ = os.Remove(old) // best-effort; may fail while the old exe is still running
271+
return nil
272+
}
273+
274+
if err := os.Rename(tmpPath, exe); err != nil {
275+
return installPermError(dir, err)
276+
}
277+
cleanup = false
278+
return nil
279+
}
280+
281+
// installPermError wraps errors that are typically permission-related with an
282+
// actionable hint.
283+
func installPermError(dir string, err error) error {
284+
if errors.Is(err, os.ErrPermission) {
285+
return fmt.Errorf("cannot write to %s: %w\nTry re-running with elevated permissions, or re-run the installer: curl -fsSL https://raw.githubusercontent.com/%s/main/install.sh | sh", dir, err, repoSlug)
286+
}
287+
return err
288+
}

0 commit comments

Comments
 (0)