diff --git a/README.md b/README.md index 3e476e9..71b650c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,20 @@ tinyagent --update This replaces the binary in place with the latest release, after checking its checksum. If it's installed in a directory you can't write to, run it with `sudo`. +Each step is printed to stderr as it happens, and the download shows a `#` progress bar when stderr is a terminal: + +```text +update: current version: v0.10.3 at /usr/local/bin/tinyagent +update: query latest release: https://api.github.com/repos/vicgupta/tinyagent/releases/latest +update: query latest release: found v0.10.4 (current v0.10.3) +update: select asset: tinyagent-linux-amd64 for linux/amd64 +update: download checksums: SHA256SUMS +update: download binary: https://github.com/vicgupta/tinyagent/releases/download/v0.10.4/tinyagent-linux-amd64 + [####################....................] 50% 4.5 MiB / 9.0 MiB +``` + +It ends with the received size, the verified SHA-256, the install step and `update: done: updated v0.10.3 -> v0.10.4 at `. If a step fails, the error names it: `error: update failed at verify checksum: ...`. See [`--update`](docs/subcommands.md#updating) for the details. + ### Shell completion (Bash, Zsh and Fish) `tinyagent --completion bash`, `tinyagent --completion zsh` and `tinyagent --completion fish` print a script that completes flag names (`--sandbox-`, `--tool-out`, short aliases such as `-t`). After `--env`, `--command-env`, `--reasoning-effort`, `--openai-api` and `--completion` (as `--flag ` or `--flag=`) they also complete that flag's fixed values, so `--env ` offers `local` and `container`. Other flag values, such as models and profiles, are not completed; those words fall back to filenames. @@ -120,6 +134,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and - Run budgets: turn cap, cost limit and token limit (`--token-limit`), with an 80% warning to the model; delegates, workers and `--attempts` share the budget, and a run that hits it can be resumed. - Verify before stop (`--verify-on-stop`): a run that changed files isn't accepted until a test or build command passes; otherwise it is marked unverified. Optional computational checks (`--verify-check`, e.g. lint, type check, tests) run in order, cheapest first, before the run may finish; see [Computational checks](docs/verify.md#computational-checks). An optional inferential review (`--verify-review`) runs last, once every check passes, and reports the model's findings; see [Inferential review](docs/verify.md#inferential-review). - Help everywhere: `tinyagent` or `tinyagent ` alone at a terminal prints help; `--help --verbose` lists every flag by section. +- `tinyagent --update`: checksum-verified self-update with step-by-step progress and a `#` download bar. - Undo for file edits, workspace rewind, resume and follow-up runs. - Sandboxed commands with a network allowlist, permission rules, hooks, untrusted-content lockdown and file-access rules. - WebAssembly plugin modules run through Extism with permission checks, a version store, rollback and prune. Go source modules are built with TinyGo, an external dependency that tinyagent downloads on first use (pinned version, checksum-verified, cached locally, `tinyagent tinygo update` to update); TinyGo is never embedded in tinyagent. diff --git a/cmd/tinyagent/main.go b/cmd/tinyagent/main.go index 8891f42..22dd815 100644 --- a/cmd/tinyagent/main.go +++ b/cmd/tinyagent/main.go @@ -351,12 +351,7 @@ func run() int { if err != nil { return fail(err) } - msg, err := u.Run(context.Background()) - if err != nil { - return fail(err) - } - fmt.Println(msg) - return 0 + return runUpdate(u, o.jsonOutput, os.Stdout, os.Stderr, isTerminal(os.Stderr)) } if o.check { return runCheck(&o, fs, os.Stdout) diff --git a/cmd/tinyagent/update.go b/cmd/tinyagent/update.go new file mode 100644 index 0000000..7eadddd --- /dev/null +++ b/cmd/tinyagent/update.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/vicgupta/tinyagent/internal/update" +) + +// runUpdate runs --update with step-by-step progress on stderr (a '#' bar +// while downloading when stderr is a terminal) and the final message on +// stdout. With --json, steps and the result are JSONL events on stdout and +// stderr gets nothing but errors. +func runUpdate(u *update.Updater, jsonOut bool, stdout, stderr io.Writer, tty bool) int { + if jsonOut { + u.Report = &update.JSONReporter{W: stdout} + } else { + u.Report = &update.TextReporter{W: stderr, Bar: tty} + } + msg, err := u.Run(context.Background()) + if jsonOut { + ev := map[string]string{"type": "update.result", "status": "ok", "message": msg} + if err != nil { + ev["status"], ev["message"] = "error", err.Error() + } + b, _ := json.Marshal(ev) + fmt.Fprintf(stdout, "%s\n", b) + } + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + if !jsonOut { + fmt.Fprintln(stdout, msg) + } + return 0 +} diff --git a/cmd/tinyagent/update_test.go b/cmd/tinyagent/update_test.go new file mode 100644 index 0000000..2ff2b2d --- /dev/null +++ b/cmd/tinyagent/update_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vicgupta/tinyagent/internal/update" +) + +func fakeUpToDate(t *testing.T) *update.Updater { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"tag_name":"v0.1.0","assets":[]}`)) + })) + t.Cleanup(srv.Close) + return &update.Updater{APIURL: srv.URL, Current: "v0.1.0", ExePath: "/x/tinyagent", Client: srv.Client(), GOOS: "linux", GOARCH: "amd64"} +} + +func TestRunUpdateText(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runUpdate(fakeUpToDate(t), false, &stdout, &stderr, false); code != 0 { + t.Fatalf("exit %d: %s", code, stderr.String()) + } + if stdout.String() != "already up to date (v0.1.0)\n" { + t.Errorf("stdout %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "update: current version: v0.1.0 at /x/tinyagent\n") || !strings.Contains(stderr.String(), "already up to date") { + t.Errorf("stderr %q", stderr.String()) + } +} + +func TestRunUpdateJSONKeepsStdoutClean(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runUpdate(fakeUpToDate(t), true, &stdout, &stderr, true); code != 0 { + t.Fatalf("exit %d", code) + } + if stderr.Len() != 0 { + t.Errorf("stderr %q", stderr.String()) + } + sc := bufio.NewScanner(&stdout) + var types []string + for sc.Scan() { + var ev map[string]string + if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { + t.Fatalf("not JSON: %q", sc.Text()) + } + types = append(types, ev["type"]) + } + if len(types) < 3 || types[0] != "update.step" || types[len(types)-1] != "update.result" { + t.Errorf("events %v", types) + } +} + +func TestRunUpdateErrorNamesStep(t *testing.T) { + u := fakeUpToDate(t) + u.APIURL += "/nope" + u.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 503, Status: "503 Service Unavailable", Body: http.NoBody}, nil + })} + var stdout, stderr bytes.Buffer + if code := runUpdate(u, false, &stdout, &stderr, false); code != 1 { + t.Fatalf("exit %d", code) + } + if !strings.Contains(stderr.String(), "error: update failed at query latest release: ") { + t.Errorf("stderr %q", stderr.String()) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/docs/json-output.md b/docs/json-output.md index a8f8994..d3ef9d0 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -2,7 +2,7 @@ ### Machine-readable output -`--json` makes stdout a UTF-8 JSONL event stream, with one complete JSON object and newline per event. It can be combined with `--no-stream`, `--read-only`, `--resume`, and `-o` (which still writes the trajectory JSON to its file). In this mode, streamed assistant text arrives as `assistant.delta` events while the model generates (unless `--no-stream`), live bash output is represented by events, and `--verbose` has no effect because tool calls and results are already events; the human summary and warnings remain on stderr. Setup failures before a run starts may leave stdout empty. +`--json` makes stdout a UTF-8 JSONL event stream, with one complete JSON object and newline per event. It can be combined with `--no-stream`, `--read-only`, `--resume`, and `-o` (which still writes the trajectory JSON to its file). In this mode, streamed assistant text arrives as `assistant.delta` events while the model generates (unless `--no-stream`), live bash output is represented by events, and `--verbose` has no effect because tool calls and results are already events; the human summary and warnings remain on stderr. Setup failures before a run starts may leave stdout empty. With `--update`, stdout carries `update.step` and `update.result` events instead (see [Updating](subcommands.md#updating)). Every event has a `type`. Event names and fields in schema version 1 are: diff --git a/docs/subcommands.md b/docs/subcommands.md index f1602b7..27b8f11 100644 --- a/docs/subcommands.md +++ b/docs/subcommands.md @@ -136,10 +136,18 @@ Six subcommands are shortcuts for existing flags. They rewrite the command line | `--no-agents-md` | off | Don't append the nearest `AGENTS.md` (at or above ``, up to the repo root; capped at 32KB) to the system prompt | | `--scoped-agents-md` | off | Also deliver `AGENTS.md` files in directories below ``, appended to the first file tool result that touches a path there; see [Project instructions](project-context.md#project-instructions-agentsmd). Can't be combined with `--no-agents-md` | | `-V, --version` | | Print the version and exit | -| `--update` | | Replace this binary with the latest GitHub release (checksum-verified) and exit | +| `--update` | | Replace this binary with the latest GitHub release (checksum-verified) and exit, printing each step to stderr; see [Updating](#updating) | | `--check` | | Print the resolved config file, model, provider key status, fallbacks, cwd and limits (and `ask_user` when it is on or set by flag, and the [project memory](memory.md#project-memory) approval policy, store path and active/pending entry counts when memory is on or `--memory` is passed, a `lockdown:` line when [lockdown](permissions.md#untrusted-content-lockdown) is on or `--lockdown` is passed, and an `attempts:` line when `--attempts` flags are passed or the config has an `attempts:` section: the validated best-of-N plan with each attempt's model, the check count and timeouts (never the commands), `stop_on_pass`, `keep` and the worktree paths), then exit without a model request (exit 2 on a setup error) | | `--completion bash\|zsh\|fish` | | Print a Bash, Zsh or Fish flag-name completion script and exit (`source <(tinyagent --completion bash)`; for Zsh, after `compinit`; for Fish, `tinyagent --completion fish \| source`); see [Shell completion](../README.md#shell-completion-bash-zsh-and-fish) | +## Updating + +`tinyagent --update` prints one `update: : ` line on stderr per step: `current version` (version and binary path), `query latest release` (the API URL, then `found vX` or `latest is vX; already up to date`), `select asset` (`tinyagent--`), `download checksums` (`SHA256SUMS`), `download binary` (the URL, then the received size), `verify checksum` (the SHA-256 that matched), `install` (the path being replaced) and `done`. The final message (`updated vA -> vB ()` or `already up to date (vA)`) goes to stdout, exit 0. + +- **Download bar.** When stderr is a terminal, the binary download draws a `#` bar: with a known `Content-Length`, one redrawn line ` [####....] 42% 3.8 MiB / 9.0 MiB`; without one, one `#` per 256 KiB received, then the total. When stderr isn't a terminal (piped, CI logs), only the plain step lines are printed, with no carriage returns. +- **Failures** exit 1 and name the step: `error: update failed at : `, for example `update failed at verify checksum: checksum mismatch for tinyagent-linux-amd64; not installing`. The binary is replaced only after the checksum matches. +- **`--json`.** Steps become JSONL events on stdout, `{"type":"update.step","step":"...","detail":"..."}`, followed by `{"type":"update.result","status":"ok"|"error","message":"..."}`; no bar is drawn and stderr only gets the error line on failure. + ## Exit status The run ends when the model writes `COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` (`Submitted`), stops calling tools (`Completed`), or hits a limit (`StepLimitExceeded`, `CostLimitExceeded`, `TokenLimitExceeded`, `TimeLimitExceeded`), Ctrl-C (`Interrupted`), a non-retryable API error (`Error`), too many consecutive malformed tool-call steps (`RepeatedFormatError`, see below), or, with `--output-schema`, 3 consecutive invalid results (`InvalidOutput`), or stop hooks still rejecting the result after `max_stop_continuations` (`VerificationFailed`, see [Stop hooks](permissions.md#stop-hooks)), or too many consecutive responses cut off at the output-token limit (`OutputTruncated`, see [Output truncation](troubleshooting.md#output-truncation)), or, with `--ask-user`, a question for the user (`NeedsInput`, see [Asking for input](runs.md#asking-for-input)). The process exits 0 only for `Submitted` and `Completed`, 3 for `OutputTruncated`, 4 for `NeedsInput`, and 1 otherwise (2 is reserved for usage errors). diff --git a/internal/update/progress.go b/internal/update/progress.go new file mode 100644 index 0000000..db0b392 --- /dev/null +++ b/internal/update/progress.go @@ -0,0 +1,147 @@ +package update + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// Step names, in the order Run goes through them. Errors from Run name the +// step that failed ("update failed at : ..."). +const ( + StepCurrent = "current version" + StepQuery = "query latest release" + StepSelect = "select asset" + StepChecksum = "download checksums" + StepDownload = "download binary" + StepVerify = "verify checksum" + StepInstall = "install" + StepDone = "done" +) + +// Reporter receives Run's progress. Step is called once per step with a +// human-readable detail; Download is called before the binary download and +// returns a writer fed every byte as it arrives (nil for none) and a finish +// func called once the body is read or fails. +type Reporter interface { + Step(step, detail string) + Download(name string, total int64) (io.Writer, func()) +} + +// TextReporter prints one "update: : " line per step to W. +// With Bar set (W is a terminal), the download draws a '#' progress bar; +// otherwise the download prints only plain lines, with no carriage returns. +type TextReporter struct { + W io.Writer + Bar bool +} + +func (r *TextReporter) Step(step, detail string) { + fmt.Fprintf(r.W, "update: %s: %s\n", step, detail) +} + +func (r *TextReporter) Download(name string, total int64) (io.Writer, func()) { + if !r.Bar { + return nil, func() {} + } + p := &ProgressBar{W: r.W, Total: total} + return p, p.Finish +} + +// BarWidth is the number of cells in a known-length progress bar. +const BarWidth = 40 + +// ChunkSize is how many bytes one '#' stands for when the length is unknown. +const ChunkSize = 256 << 10 + +// ProgressBar draws download progress to W. With Total > 0 it redraws one +// line, "\r [####....] 42% 1.2 MiB / 2.9 MiB", as bytes arrive; with +// Total <= 0 it appends one '#' per ChunkSize bytes. Finish ends the line. +type ProgressBar struct { + W io.Writer + Total int64 + n int64 + drawn int // known: last percent drawn (-1 before any); unknown: '#'s printed + began bool +} + +func (p *ProgressBar) Write(b []byte) (int, error) { + p.n += int64(len(b)) + if p.Total > 0 { + pct := int(p.n * 100 / p.Total) + if pct > 100 { + pct = 100 + } + if !p.began || pct != p.drawn { + p.began = true + p.drawn = pct + p.draw(pct) + } + return len(b), nil + } + if !p.began { + p.began = true + fmt.Fprint(p.W, " ") + } + for want := int(p.n / ChunkSize); p.drawn < want; p.drawn++ { + fmt.Fprint(p.W, "#") + } + return len(b), nil +} + +func (p *ProgressBar) draw(pct int) { + fill := pct * BarWidth / 100 + fmt.Fprintf(p.W, "\r [%s%s] %3d%% %s / %s", strings.Repeat("#", fill), strings.Repeat(".", BarWidth-fill), pct, Size(p.n), Size(p.Total)) +} + +// Finish ends the progress line. +func (p *ProgressBar) Finish() { + if p.Total > 0 { + if !p.began { + p.draw(0) + } + fmt.Fprintln(p.W) + return + } + if !p.began { + fmt.Fprint(p.W, " ") + } + fmt.Fprintf(p.W, " %s\n", Size(p.n)) +} + +// Size formats a byte count as B, KiB or MiB. +func Size(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) + } + return fmt.Sprintf("%d B", n) +} + +// JSONReporter writes each step as a JSONL event +// {"type":"update.step","step":...,"detail":...} to W, and no progress bar. +type JSONReporter struct{ W io.Writer } + +func (r *JSONReporter) Step(step, detail string) { + b, _ := json.Marshal(map[string]string{"type": "update.step", "step": step, "detail": detail}) + fmt.Fprintf(r.W, "%s\n", b) +} + +func (r *JSONReporter) Download(string, int64) (io.Writer, func()) { return nil, func() {} } + +type nopReporter struct{} + +func (nopReporter) Step(string, string) {} +func (nopReporter) Download(string, int64) (io.Writer, func()) { return nil, func() {} } + +// StepError is an error from one step of Run. +type StepError struct { + Step string + Err error +} + +func (e *StepError) Error() string { return fmt.Sprintf("update failed at %s: %v", e.Step, e.Err) } +func (e *StepError) Unwrap() error { return e.Err } diff --git a/internal/update/progress_test.go b/internal/update/progress_test.go new file mode 100644 index 0000000..8d83587 --- /dev/null +++ b/internal/update/progress_test.go @@ -0,0 +1,136 @@ +package update + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" +) + +func TestProgressBarKnownLength(t *testing.T) { + var out bytes.Buffer + p := &ProgressBar{W: &out, Total: 1000} + for i := 0; i < 4; i++ { + p.Write(make([]byte, 250)) + } + p.Finish() + s := out.String() + for _, want := range []string{" 25%", " 50%", " 75%", "100%", "1000 B / 1000 B", "[" + strings.Repeat("#", BarWidth) + "]"} { + if !strings.Contains(s, want) { + t.Errorf("missing %q in %q", want, s) + } + } + if strings.Count(s, "\r") != 4 || !strings.HasSuffix(s, "\n") { + t.Errorf("want 4 redraws and a final newline: %q", s) + } + // Writes that don't change the percent don't redraw. + out.Reset() + p = &ProgressBar{W: &out, Total: 1 << 20} + p.Write([]byte{1}) + p.Write([]byte{1}) + if strings.Count(out.String(), "\r") != 1 { + t.Errorf("redrew without progress: %q", out.String()) + } +} + +func TestProgressBarUnknownLength(t *testing.T) { + var out bytes.Buffer + p := &ProgressBar{W: &out, Total: -1} + for i := 0; i < 5; i++ { + p.Write(make([]byte, ChunkSize/2)) + } + p.Finish() + if got, want := out.String(), " ## 640.0 KiB\n"; got != want { + t.Errorf("got %q, want %q", got, want) + } + if strings.Contains(out.String(), "\r") { + t.Error("unknown-length bar must not redraw") + } +} + +func TestRunReportsSteps(t *testing.T) { + bin := bytes.Repeat([]byte("x"), 3*ChunkSize) + sum := sha256.Sum256(bin) + u := newTestUpdater(t, fakeRelease(t, "v0.2.0", bin, hex.EncodeToString(sum[:])), "v0.1.0") + var out bytes.Buffer + u.Report = &TextReporter{W: &out} + if _, err := u.Run(context.Background()); err != nil { + t.Fatal(err) + } + s := out.String() + order := []string{ + "update: current version: v0.1.0 at ", + "update: query latest release: http", + "update: query latest release: found v0.2.0 (current v0.1.0)", + "update: select asset: tinyagent-linux-arm64 for linux/arm64", + "update: download checksums: SHA256SUMS", + "update: download binary: http", + "update: download binary: received 768.0 KiB (786432 bytes)", + "update: verify checksum: sha256 " + hex.EncodeToString(sum[:]) + " matches", + "update: install: replacing ", + "update: done: updated v0.1.0 -> v0.2.0 at ", + } + at := 0 + for _, w := range order { + i := strings.Index(s[at:], w) + if i < 0 { + t.Fatalf("missing or out of order %q in:\n%s", w, s) + } + at += i + len(w) + } + if strings.Contains(s, "\r") || strings.Contains(s, "#") { + t.Errorf("non-terminal output has a bar:\n%s", s) + } + + // On a terminal the download draws the bar between the download lines. + u = newTestUpdater(t, fakeRelease(t, "v0.2.0", bin, hex.EncodeToString(sum[:])), "v0.1.0") + out.Reset() + u.Report = &TextReporter{W: &out, Bar: true} + if _, err := u.Run(context.Background()); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "100% 768.0 KiB / 768.0 KiB\nupdate: download binary: received") { + t.Errorf("no bar:\n%s", out.String()) + } +} + +func TestRunUpToDateReportsSteps(t *testing.T) { + u := newTestUpdater(t, fakeRelease(t, "v0.1.0", nil, ""), "v0.1.0") + var out bytes.Buffer + u.Report = &TextReporter{W: &out} + if _, err := u.Run(context.Background()); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "update: query latest release: latest is v0.1.0; already up to date") || strings.Contains(out.String(), "download") { + t.Errorf("got:\n%s", out.String()) + } +} + +func TestRunErrorsNameTheStep(t *testing.T) { + bad := newTestUpdater(t, fakeRelease(t, "v0.2.0", []byte("tampered"), strings.Repeat("a", 64)), "v0.1.0") + plat := newTestUpdater(t, fakeRelease(t, "v0.2.0", nil, ""), "v0.1.0") + plat.GOOS = "plan9" + down := newTestUpdater(t, fakeRelease(t, "v0.2.0", nil, ""), "v0.1.0") + down.APIURL += "-missing" + for _, c := range []struct { + u *Updater + step string + }{{bad, StepVerify}, {plat, StepSelect}, {down, StepQuery}} { + _, err := c.u.Run(context.Background()) + var se *StepError + if !errors.As(err, &se) || se.Step != c.step || !strings.HasPrefix(err.Error(), "update failed at "+c.step+": ") { + t.Errorf("want failure at %q, got %v", c.step, err) + } + } +} + +func TestJSONReporter(t *testing.T) { + var out bytes.Buffer + (&JSONReporter{W: &out}).Step(StepQuery, "found v1") + if got := out.String(); got != `{"detail":"found v1","step":"query latest release","type":"update.step"}`+"\n" { + t.Errorf("got %q", got) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 2cb0848..681c63d 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -28,6 +28,7 @@ type Updater struct { Client *http.Client GOOS string GOARCH string + Report Reporter // progress; nil reports nothing } func New(current string) (*Updater, error) { @@ -57,15 +58,26 @@ type release struct { } // Run installs the latest release if it is newer than Current and returns a -// message describing what happened. +// message describing what happened. Each step is reported to u.Report, and +// an error is a *StepError naming the step that failed. func (u *Updater) Run(ctx context.Context) (string, error) { + r := u.Report + if r == nil { + r = nopReporter{} + } + fail := func(step string, err error) (string, error) { return "", &StepError{Step: step, Err: err} } + + r.Step(StepCurrent, fmt.Sprintf("%s at %s", u.Current, u.ExePath)) + r.Step(StepQuery, u.APIURL) var rel release if err := u.getJSON(ctx, u.APIURL, &rel); err != nil { - return "", fmt.Errorf("fetch latest release: %w", err) + return fail(StepQuery, err) } if !newer(rel.TagName, u.Current) { + r.Step(StepQuery, fmt.Sprintf("latest is %s; already up to date", rel.TagName)) return fmt.Sprintf("already up to date (%s)", u.Current), nil } + r.Step(StepQuery, fmt.Sprintf("found %s (current %s)", rel.TagName, u.Current)) name := fmt.Sprintf("tinyagent-%s-%s", u.GOOS, u.GOARCH) if u.GOOS == "windows" { @@ -76,30 +88,41 @@ func (u *Updater) Run(ctx context.Context) (string, error) { urls[a.Name] = a.URL } if urls[name] == "" { - return "", fmt.Errorf("release %s has no binary for %s/%s", rel.TagName, u.GOOS, u.GOARCH) + return fail(StepSelect, fmt.Errorf("release %s has no binary for %s/%s", rel.TagName, u.GOOS, u.GOARCH)) } if urls["SHA256SUMS"] == "" { - return "", fmt.Errorf("release %s has no SHA256SUMS", rel.TagName) + return fail(StepSelect, fmt.Errorf("release %s has no SHA256SUMS", rel.TagName)) } + r.Step(StepSelect, fmt.Sprintf("%s for %s/%s", name, u.GOOS, u.GOARCH)) + r.Step(StepChecksum, "SHA256SUMS") sums, err := u.get(ctx, urls["SHA256SUMS"]) if err != nil { - return "", fmt.Errorf("download SHA256SUMS: %w", err) + return fail(StepChecksum, err) } want, err := checksumFor(sums, name) if err != nil { - return "", err + return fail(StepChecksum, err) } - bin, err := u.get(ctx, urls[name]) + + r.Step(StepDownload, urls[name]) + bin, err := u.download(ctx, urls[name], name, r) if err != nil { - return "", fmt.Errorf("download %s: %w", name, err) + return fail(StepDownload, err) } - if got := sha256.Sum256(bin); hex.EncodeToString(got[:]) != want { - return "", fmt.Errorf("checksum mismatch for %s; not installing", name) + r.Step(StepDownload, fmt.Sprintf("received %s (%d bytes)", Size(int64(len(bin))), len(bin))) + + got := sha256.Sum256(bin) + if hex.EncodeToString(got[:]) != want { + return fail(StepVerify, fmt.Errorf("checksum mismatch for %s; not installing", name)) } + r.Step(StepVerify, "sha256 "+want+" matches SHA256SUMS") + + r.Step(StepInstall, "replacing "+u.ExePath) if err := replace(u.ExePath, bin); err != nil { - return "", fmt.Errorf("install: %w", err) + return fail(StepInstall, err) } + r.Step(StepDone, fmt.Sprintf("updated %s -> %s at %s", u.Current, rel.TagName, u.ExePath)) return fmt.Sprintf("updated %s -> %s (%s)", u.Current, rel.TagName, u.ExePath), nil } @@ -184,6 +207,16 @@ func parse(v string) ([3]int, bool) { } func (u *Updater) get(ctx context.Context, url string) ([]byte, error) { + resp, err := u.open(ctx, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// open GETs url and returns the response when the status is 200. +func (u *Updater) open(ctx context.Context, url string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -193,11 +226,29 @@ func (u *Updater) get(ctx context.Context, url string) ([]byte, error) { if err != nil { return nil, err } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + resp.Body.Close() return nil, fmt.Errorf("GET %s: %s", url, resp.Status) } - return io.ReadAll(resp.Body) + return resp, nil +} + +// download GETs url and streams the body through the reporter's progress +// writer as it arrives. +func (u *Updater) download(ctx context.Context, url, name string, r Reporter) ([]byte, error) { + resp, err := u.open(ctx, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + w, finish := r.Download(name, resp.ContentLength) + var body io.Reader = resp.Body + if w != nil { + body = io.TeeReader(resp.Body, w) + } + data, err := io.ReadAll(body) + finish() + return data, err } func (u *Updater) getJSON(ctx context.Context, url string, out any) error { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index baa0ef8..f6e5fe5 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" ) @@ -44,6 +45,7 @@ func fakeRelease(t *testing.T, tag string, bin []byte, sum string) *httptest.Ser {"name":"tinyagent-linux-arm64","browser_download_url":"%s/bin"}, {"name":"SHA256SUMS","browser_download_url":"%s/sums"}]}`, tag, srv.URL, srv.URL) case "/bin": + w.Header().Set("Content-Length", strconv.Itoa(len(bin))) w.Write(bin) case "/sums": fmt.Fprintf(w, "%s tinyagent-linux-amd64\n%s tinyagent-linux-arm64\n", strings.Repeat("0", 64), sum)