Skip to content

Commit 2a0ea86

Browse files
authored
feat(cli): add odek upgrade self-update command (#119)
Self-upgrade from GitHub Releases with OS/arch autodetection: - Maps runtime.GOOS/GOARCH to the odek-<goos>-<goarch> release asset; unsupported platforms get a clear build-from-source error - Fetches the latest release via the GitHub API and semver-compares against getVersion(); dev/commit-hash builds always count as older - Verifies the download against the release checksums.txt (SHA-256) and refuses unverifiable or mismatching installs - Installs atomically: temp dir next to the target binary, chmod to the original mode, os.Rename over the running binary; permission failures suggest sudo - --check reports the available upgrade without installing Tests (httptest fake release server): version compare, checksum parsing, platform mapping, end-to-end replace with mode preservation, up-to-date short-circuit, --check no-touch, checksum-mismatch refusal, API parsing. Also verified live: dev binary upgraded itself to v1.16.0 from GitHub.
1 parent 9a0c0fa commit 2a0ea86

6 files changed

Lines changed: 540 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ cmd/odek/
4848
schedule.go `odek schedule` command and scheduler wiring
4949
memory_cmd.go `odek memory` command
5050
cleanup.go `odek cleanup [--dry-run]` one-shot storage sweep + janitor wiring (telegram/serve/schedule daemon)
51+
upgrade.go `odek upgrade [--check]` self-upgrade from GitHub Releases (OS/arch autodetection, checksums.txt SHA-256 verification, atomic binary replace)
5152
parallel.go Parallelism helpers
5253
toolctx.go Tool-call context plumbing
5354
security_report_validation_test.go Regression bar for every documented mitigation

cmd/odek/dispatch.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ func dispatch(args []string) int {
5757
return cliExit(memoryCmd(rest))
5858
case "cleanup":
5959
return cliExit(cleanupCmd(rest))
60+
case "upgrade":
61+
return cliExit(upgradeCmd(rest))
6062
default:
6163
fmt.Fprintf(os.Stderr, "odek: unknown command %q\n", cmd)
6264
printUsage()

cmd/odek/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,7 @@ func printUsage() {
848848
odek schedule <list|add|rm|enable|disable|run|next|daemon>
849849
odek memory <list|promote <session_id>>
850850
odek cleanup [--dry-run]
851+
odek upgrade [--check]
851852
odek version
852853
853854
Commands:
@@ -880,6 +881,11 @@ Commands:
880881
cleanup One-shot storage sweep of ~/.odek (sessions, audit,
881882
plans, skill skips, log rotation). --dry-run previews.
882883
init Create a config file (default: ./odek.json)
884+
upgrade Self-upgrade to the latest GitHub release
885+
Downloads the asset for the current OS/arch, verifies
886+
it against the release checksums.txt (SHA-256), and
887+
installs it atomically. --check reports without
888+
installing.
883889
version Print version and exit
884890
885891
Init flags:

cmd/odek/upgrade.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"runtime"
14+
"strconv"
15+
"strings"
16+
"time"
17+
18+
"github.com/BackendStack21/odek/internal/transport"
19+
)
20+
21+
// upgradeCmd implements `odek upgrade [--check]`: a self-upgrade from
22+
// GitHub Releases. The platform asset is selected by OS/arch autodetection
23+
// (runtime.GOOS/GOARCH → odek-<goos>-<goarch>), the download is verified
24+
// against the release's checksums.txt, and the current binary is replaced
25+
// atomically (temp file + rename in the same directory).
26+
func upgradeCmd(args []string) error {
27+
checkOnly := false
28+
for _, a := range args {
29+
switch a {
30+
case "--check":
31+
checkOnly = true
32+
case "--help", "-h":
33+
fmt.Println(`Usage: odek upgrade [--check]
34+
35+
Upgrade the odek binary to the latest GitHub release. The asset matching
36+
the current OS/architecture is downloaded, verified against the release's
37+
checksums.txt (SHA-256), and installed atomically over the running binary.
38+
39+
--check Report the latest version without installing anything.`)
40+
return nil
41+
default:
42+
return fmt.Errorf("unknown flag %q for upgrade", a)
43+
}
44+
}
45+
46+
exe, err := os.Executable()
47+
if err != nil {
48+
return fmt.Errorf("upgrade: cannot locate current executable: %w", err)
49+
}
50+
exe, err = filepath.EvalSymlinks(exe)
51+
if err != nil {
52+
return fmt.Errorf("upgrade: cannot resolve current executable: %w", err)
53+
}
54+
55+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
56+
defer cancel()
57+
client := transport.NewPooledClient(60 * time.Second)
58+
59+
_, err = performUpgrade(ctx, client, githubAPIBase, exe, getVersion(), checkOnly, os.Stdout)
60+
return err
61+
}
62+
63+
const (
64+
// githubAPIBase is the GitHub API root used to resolve the latest
65+
// release. Asset downloads follow the browser_download_url returned by
66+
// the API. Kept as a variable so tests can point at a fake server.
67+
githubAPIBase = "https://api.github.com"
68+
githubRepo = "BackendStack21/odek"
69+
)
70+
71+
// ghRelease is the subset of the GitHub release payload upgrade needs.
72+
type ghRelease struct {
73+
TagName string `json:"tag_name"`
74+
Assets []struct {
75+
Name string `json:"name"`
76+
BrowserDownloadURL string `json:"browser_download_url"`
77+
} `json:"assets"`
78+
}
79+
80+
// assetURL returns the download URL for the named asset, or "".
81+
func (r *ghRelease) assetURL(name string) string {
82+
for _, a := range r.Assets {
83+
if a.Name == name {
84+
return a.BrowserDownloadURL
85+
}
86+
}
87+
return ""
88+
}
89+
90+
// fetchLatestRelease queries the GitHub API for the repository's latest
91+
// published release.
92+
func fetchLatestRelease(ctx context.Context, client *http.Client, apiBase string) (*ghRelease, error) {
93+
url := strings.TrimSuffix(apiBase, "/") + "/repos/" + githubRepo + "/releases/latest"
94+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
95+
if err != nil {
96+
return nil, err
97+
}
98+
req.Header.Set("Accept", "application/vnd.github+json")
99+
resp, err := client.Do(req)
100+
if err != nil {
101+
return nil, fmt.Errorf("cannot reach GitHub: %w", err)
102+
}
103+
defer resp.Body.Close()
104+
if resp.StatusCode != http.StatusOK {
105+
return nil, fmt.Errorf("GitHub API returned %s", resp.Status)
106+
}
107+
var rel ghRelease
108+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rel); err != nil {
109+
return nil, fmt.Errorf("cannot parse GitHub release payload: %w", err)
110+
}
111+
if rel.TagName == "" {
112+
return nil, fmt.Errorf("GitHub release payload has no tag_name")
113+
}
114+
return &rel, nil
115+
}
116+
117+
// parseChecksums parses a checksums.txt body ("<sha256> <name>" per line)
118+
// into a name→hash map.
119+
func parseChecksums(data []byte) map[string]string {
120+
out := make(map[string]string)
121+
for _, line := range strings.Split(string(data), "\n") {
122+
fields := strings.Fields(line)
123+
if len(fields) == 2 && len(fields[0]) == 64 {
124+
out[fields[1]] = fields[0]
125+
}
126+
}
127+
return out
128+
}
129+
130+
// compareVersions compares two "vX.Y.Z" versions, returning -1, 0, or 1.
131+
// A current version that does not parse as semver (e.g. "dev" or a commit
132+
// hash from a local build) is treated as older than any release.
133+
func compareVersions(current, latest string) int {
134+
c, cok := parseSemver(current)
135+
l, lok := parseSemver(latest)
136+
if !lok {
137+
return 0 // unknown latest — caller errors out before comparing
138+
}
139+
if !cok {
140+
return -1
141+
}
142+
for i := 0; i < 3; i++ {
143+
if c[i] != l[i] {
144+
if c[i] < l[i] {
145+
return -1
146+
}
147+
return 1
148+
}
149+
}
150+
return 0
151+
}
152+
153+
func parseSemver(v string) ([3]int, bool) {
154+
var out [3]int
155+
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
156+
parts := strings.Split(v, ".")
157+
if len(parts) != 3 {
158+
return out, false
159+
}
160+
for i, p := range parts {
161+
n, err := strconv.Atoi(p)
162+
if err != nil || n < 0 {
163+
return out, false
164+
}
165+
out[i] = n
166+
}
167+
return out, true
168+
}
169+
170+
// upgradeAssetName maps the running platform to its release asset name.
171+
func upgradeAssetName() (string, error) {
172+
name := "odek-" + runtime.GOOS + "-" + runtime.GOARCH
173+
switch name {
174+
case "odek-darwin-amd64", "odek-darwin-arm64", "odek-linux-amd64", "odek-linux-arm64":
175+
return name, nil
176+
}
177+
return "", fmt.Errorf("no prebuilt release for %s/%s — build from source instead", runtime.GOOS, runtime.GOARCH)
178+
}
179+
180+
// performUpgrade runs the full upgrade flow against apiBase, replacing the
181+
// binary at exePath. It returns the installed (or available, with
182+
// checkOnly) version. The empty string with nil error means "already up to
183+
// date".
184+
func performUpgrade(ctx context.Context, client *http.Client, apiBase, exePath, current string, checkOnly bool, out io.Writer) (string, error) {
185+
asset, err := upgradeAssetName()
186+
if err != nil {
187+
return "", err
188+
}
189+
190+
rel, err := fetchLatestRelease(ctx, client, apiBase)
191+
if err != nil {
192+
return "", err
193+
}
194+
latest := rel.TagName
195+
if _, ok := parseSemver(latest); !ok {
196+
return "", fmt.Errorf("latest release tag %q is not a semantic version", latest)
197+
}
198+
199+
cmp := compareVersions(current, latest)
200+
if cmp >= 0 {
201+
fmt.Fprintf(out, "odek %s is up to date (latest: %s)\n", current, latest)
202+
return "", nil
203+
}
204+
fmt.Fprintf(out, "upgrade available: %s → %s (%s/%s)\n", current, latest, runtime.GOOS, runtime.GOARCH)
205+
if checkOnly {
206+
return latest, nil
207+
}
208+
209+
assetURL := rel.assetURL(asset)
210+
if assetURL == "" {
211+
return "", fmt.Errorf("release %s has no asset %q", latest, asset)
212+
}
213+
checksumsURL := rel.assetURL("checksums.txt")
214+
if checksumsURL == "" {
215+
return "", fmt.Errorf("release %s has no checksums.txt — refusing to install an unverifiable binary", latest)
216+
}
217+
218+
// Download into a temp dir next to the target so the final rename stays
219+
// on the same filesystem (atomic).
220+
tmpDir, err := os.MkdirTemp(filepath.Dir(exePath), ".odek-upgrade-*")
221+
if err != nil {
222+
return "", fmt.Errorf("cannot create temp dir next to %s: %w", exePath, err)
223+
}
224+
defer os.RemoveAll(tmpDir)
225+
226+
checksumData, err := downloadBytes(ctx, client, checksumsURL)
227+
if err != nil {
228+
return "", fmt.Errorf("cannot download checksums.txt: %w", err)
229+
}
230+
wantSum := parseChecksums(checksumData)[asset]
231+
if wantSum == "" {
232+
return "", fmt.Errorf("checksums.txt has no entry for %q — refusing to install", asset)
233+
}
234+
235+
tmpBin := filepath.Join(tmpDir, asset)
236+
if err := downloadToFile(ctx, client, assetURL, tmpBin); err != nil {
237+
return "", fmt.Errorf("cannot download %s: %w", asset, err)
238+
}
239+
gotSum, err := fileSHA256(tmpBin)
240+
if err != nil {
241+
return "", err
242+
}
243+
if !strings.EqualFold(gotSum, wantSum) {
244+
return "", fmt.Errorf("checksum mismatch for %s (got %s, want %s) — refusing to install", asset, gotSum, wantSum)
245+
}
246+
247+
// Preserve the installed binary's permission bits.
248+
fi, err := os.Stat(exePath)
249+
if err != nil {
250+
return "", fmt.Errorf("cannot stat %s: %w", exePath, err)
251+
}
252+
if err := os.Chmod(tmpBin, fi.Mode()); err != nil {
253+
return "", fmt.Errorf("cannot set permissions: %w", err)
254+
}
255+
if err := os.Rename(tmpBin, exePath); err != nil {
256+
if os.IsPermission(err) {
257+
return "", fmt.Errorf("cannot replace %s: permission denied — retry with elevated privileges (e.g. sudo odek upgrade)", exePath)
258+
}
259+
return "", fmt.Errorf("cannot replace %s: %w", exePath, err)
260+
}
261+
262+
fmt.Fprintf(out, "upgraded %s → %s (%s)\n", current, latest, exePath)
263+
return latest, nil
264+
}
265+
266+
func downloadBytes(ctx context.Context, client *http.Client, url string) ([]byte, error) {
267+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
268+
if err != nil {
269+
return nil, err
270+
}
271+
resp, err := client.Do(req)
272+
if err != nil {
273+
return nil, err
274+
}
275+
defer resp.Body.Close()
276+
if resp.StatusCode != http.StatusOK {
277+
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
278+
}
279+
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
280+
}
281+
282+
func downloadToFile(ctx context.Context, client *http.Client, url, dst string) error {
283+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
284+
if err != nil {
285+
return err
286+
}
287+
resp, err := client.Do(req)
288+
if err != nil {
289+
return err
290+
}
291+
defer resp.Body.Close()
292+
if resp.StatusCode != http.StatusOK {
293+
return fmt.Errorf("GET %s: %s", url, resp.Status)
294+
}
295+
// Cap the download at 256 MiB — release binaries are ~12 MB.
296+
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o755)
297+
if err != nil {
298+
return err
299+
}
300+
_, copyErr := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
301+
closeErr := f.Close()
302+
if copyErr != nil {
303+
return copyErr
304+
}
305+
return closeErr
306+
}
307+
308+
func fileSHA256(path string) (string, error) {
309+
f, err := os.Open(path)
310+
if err != nil {
311+
return "", err
312+
}
313+
defer f.Close()
314+
h := sha256.New()
315+
if _, err := io.Copy(h, f); err != nil {
316+
return "", err
317+
}
318+
return hex.EncodeToString(h.Sum(nil)), nil
319+
}

0 commit comments

Comments
 (0)