diff --git a/cmd/logos/doctor.go b/cmd/logos/doctor.go index ab820ed6..98820060 100644 --- a/cmd/logos/doctor.go +++ b/cmd/logos/doctor.go @@ -14,11 +14,14 @@ import ( "github.com/Coder8124/logos/internal/provider" "github.com/Coder8124/logos/internal/router" "github.com/Coder8124/logos/internal/secretary" + "github.com/Coder8124/logos/internal/selfupdate" "github.com/Coder8124/logos/internal/session" "github.com/Coder8124/logos/internal/setup" ) -func doctor(probe, verbose bool) error { +// updates is nil for a plain doctor, which stays offline; --check-updates +// passes the options for the one request to GitHub (tests pass a fake one). +func doctor(probe, verbose bool, updates *selfupdate.Options) error { // The product first, the model plumbing second. This used to be the other // way round — and in fact only ever reported the plumbing, so a vault that // did not exist and an index a week stale both passed silently. @@ -27,6 +30,9 @@ func doctor(probe, verbose bool) error { // one command a confused user reaches for refuse to run precisely when // something was wrong. rep := gatherHealth() + if updates != nil { + rep.Checks = append(rep.Checks, updateCheck(version, *updates, runningInstall())) + } fmt.Println("─── logos ───") // Width from the longest check name rather than a constant. "abandoned // sessions" is eighteen characters and used to push its own state out of the @@ -49,6 +55,12 @@ func doctor(probe, verbose bool) error { } ok, warn, failed, unknown := rep.Counts() fmt.Printf("\n %d ok · %d to do · %d failed · %d unchecked\n", ok, warn, failed, unknown) + if updates == nil && !selfupdate.IsDevBuild(version) { + // Said rather than left out, so a doctor that never mentions the + // version is not read as "you are up to date". Not on a dev build, + // which has no release to compare with. + fmt.Printf("\nrun `logos doctor --check-updates` to compare logos %s with the latest release (one request to GitHub)\n", version) + } // Runtimes, tiers and the web bridge are for `ask`, the rollup and the // browser extension. No continuity tool uses them, and printed by default diff --git a/cmd/logos/doctor_lead_test.go b/cmd/logos/doctor_lead_test.go index 11453d3d..b78b7108 100644 --- a/cmd/logos/doctor_lead_test.go +++ b/cmd/logos/doctor_lead_test.go @@ -27,7 +27,7 @@ func TestDoctorLeadsWithTheVaultHostsAndCheckpointAndHidesTheModelsUnlessAsked(t t.Setenv("LOGOS_VAULT", vault) emptyRuntime(t, "Ollama") - out := captureStdout(t, func() { doctor(false, false) }) + out := captureStdout(t, func() { doctor(false, false, nil) }) rows := regexp.MustCompile(`(?m)^ (\S.*?)\s{2,}(ok|to do|FAILED|unchecked)$`).FindAllStringSubmatch(out, 3) var lead []string @@ -46,7 +46,7 @@ func TestDoctorLeadsWithTheVaultHostsAndCheckpointAndHidesTheModelsUnlessAsked(t t.Errorf("doctor did not say how to see the runtimes:\n%s", out) } - verbose := captureStdout(t, func() { doctor(false, true) }) + verbose := captureStdout(t, func() { doctor(false, true, nil) }) for _, shown := range []string{"─── runtimes", "─── tiers", "web bridge:"} { if !strings.Contains(verbose, shown) { t.Errorf("doctor --verbose did not print %q:\n%s", shown, verbose) diff --git a/cmd/logos/doctor_runtime_test.go b/cmd/logos/doctor_runtime_test.go index bdd57bfb..1b4d3959 100644 --- a/cmd/logos/doctor_runtime_test.go +++ b/cmd/logos/doctor_runtime_test.go @@ -43,7 +43,7 @@ func TestDoctorDoesNotSayNothingDependsOnARuntimeItJustReportedDown(t *testing.T t.Setenv("LOGOS_VAULT", t.TempDir()) t.Setenv("LOGOS_RUNTIME", "http://127.0.0.1:1") - out := captureStdout(t, func() { doctor(false, true) }) + out := captureStdout(t, func() { doctor(false, true, nil) }) if strings.Contains(out, "nothing above depends on one") { t.Errorf("doctor waved away the runtime it reported failed:\n%s", out) diff --git a/cmd/logos/doctor_update.go b/cmd/logos/doctor_update.go new file mode 100644 index 00000000..2e8f2a81 --- /dev/null +++ b/cmd/logos/doctor_update.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Coder8124/logos/internal/health" + "github.com/Coder8124/logos/internal/selfupdate" +) + +// updateCheck is doctor's "version" row under --check-updates: whether this +// binary is behind the latest release, and the command that updates it the way +// it was installed. +// +// Behind a flag, never in a plain doctor: it asks GitHub, and nothing leaves +// the machine unless the user typed the command that needs it. It is the same +// one bare GET `logos update --check` makes, so `doctor --egress` still lists +// a single address for both. +// +// A failed request is Unknown, not Failed: being offline is not a broken +// install, and the doctor that refuses to pass on a train is one people stop +// running. It is still said, with the reason, rather than dropped. +func updateCheck(current string, opts selfupdate.Options, kind selfupdate.InstallKind) health.Check { + c := health.Check{Name: "version"} + if selfupdate.IsDevBuild(current) { + c.State = health.Unknown + c.Detail = "an unstamped dev build; there is no release to compare it with" + return c + } + rel, err := selfupdate.CheckOnly(current, opts) + if err != nil { + c.State = health.Unknown + c.Detail = fmt.Sprintf("logos %s; could not ask GitHub for the latest release: %v", current, err) + return c + } + switch { + case selfupdate.Newer(rel.Version, current): + c.State = health.Warn + c.Detail = fmt.Sprintf("logos %s is out of date; the latest release is %s", current, rel.Version) + c.Fix = upgradeCommand(kind) + case selfupdate.Newer(current, rel.Version): + c.State = health.OK + c.Detail = fmt.Sprintf("logos %s, newer than the latest release (%s)", current, rel.Version) + default: + // Compared as versions, not strings: the tag is "v0.5.8" and a + // stamped binary may say "0.5.8". + c.State = health.OK + c.Detail = fmt.Sprintf("logos %s, the latest release", current) + } + return c +} + +// upgradeCommand is the update that sticks for how this copy was installed. +// `logos update` on a Homebrew binary is undone by the next `brew upgrade`, and +// on an npm one leaves npm's own version metadata saying the old release. +func upgradeCommand(kind selfupdate.InstallKind) string { + switch kind { + case selfupdate.Homebrew: + return "brew upgrade " + selfupdate.HomebrewFormula + case selfupdate.NPMManaged: + return "npm i -g @noeton/logos@latest" + case selfupdate.NPX: + return "npx @noeton/logos@latest, or install it: npm i -g @noeton/logos" + } + return "logos update" +} + +// runningInstall is how the logos running now was installed. An unresolvable +// path is a standalone binary, whose fix, `logos update`, says for itself if it +// cannot replace anything. +func runningInstall() selfupdate.InstallKind { + exe, err := os.Executable() + if err != nil { + return selfupdate.Standalone + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return selfupdate.DetectInstall(exe) +} diff --git a/cmd/logos/doctor_update_test.go b/cmd/logos/doctor_update_test.go new file mode 100644 index 00000000..ea0c0d04 --- /dev/null +++ b/cmd/logos/doctor_update_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Coder8124/logos/internal/health" + "github.com/Coder8124/logos/internal/selfupdate" +) + +// fakeReleases answers GitHub's latest-release request with tag, and counts +// the requests so a test can assert the plain doctor made none. +func fakeReleases(t *testing.T, tag string, status int) (*selfupdate.Options, *int) { + t.Helper() + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if status != http.StatusOK { + w.WriteHeader(status) + return + } + fmt.Fprintf(w, `{"tag_name":%q,"assets":[]}`, tag) + })) + t.Cleanup(srv.Close) + return &selfupdate.Options{Client: &selfupdate.Client{ + HTTP: srv.Client(), APIBase: srv.URL, Repo: "Coder8124/logos", Agent: "logos-test", + }}, &hits +} + +func TestAnOutOfDateBinaryIsToldTheUpgradeForHowItWasInstalled(t *testing.T) { + opts, _ := fakeReleases(t, "v0.5.8", http.StatusOK) + for kind, want := range map[selfupdate.InstallKind]string{ + selfupdate.Homebrew: "brew upgrade logos-mcp", + selfupdate.NPMManaged: "npm i -g @noeton/logos@latest", + selfupdate.Standalone: "logos update", + } { + c := updateCheck("0.5.7", *opts, kind) + if c.State != health.Warn || !strings.Contains(c.Detail, "v0.5.8") || c.Fix != want { + t.Errorf("kind %v: got %+v, want a warning naming v0.5.8 with fix %q", kind, c, want) + } + } +} + +func TestTheLatestReleaseIsUpToDateWhetherOrNotItsTagHasAV(t *testing.T) { + opts, _ := fakeReleases(t, "v0.5.8", http.StatusOK) + for _, current := range []string{"0.5.8", "v0.5.8"} { + c := updateCheck(current, *opts, selfupdate.Standalone) + if c.State != health.OK || c.Fix != "" || !strings.Contains(c.Detail, "latest release") { + t.Errorf("%s: got %+v, want ok and the latest release", current, c) + } + } +} + +func TestABuildAheadOfTheLatestReleaseIsNotToldToDowngrade(t *testing.T) { + opts, _ := fakeReleases(t, "v0.5.7", http.StatusOK) + c := updateCheck("0.5.8", *opts, selfupdate.Standalone) + if c.State != health.OK || c.Fix != "" || !strings.Contains(c.Detail, "newer than") { + t.Errorf("got %+v, want ok and newer than the release", c) + } +} + +func TestAFailedReleaseCheckIsSaidNotPassedOff(t *testing.T) { + opts, _ := fakeReleases(t, "", http.StatusServiceUnavailable) + c := updateCheck("0.5.7", *opts, selfupdate.Standalone) + if c.State != health.Unknown || !strings.Contains(c.Detail, "503") { + t.Errorf("got %+v, want unchecked with the reason", c) + } +} + +func TestADevBuildAsksNobody(t *testing.T) { + opts, hits := fakeReleases(t, "v0.5.8", http.StatusOK) + c := updateCheck("dev", *opts, selfupdate.Standalone) + if c.State != health.Unknown || *hits != 0 { + t.Errorf("got %+v after %d requests, want unchecked and none", c, *hits) + } +} + +func TestAPlainDoctorStaysOfflineAndSaysHowToCheckTheVersion(t *testing.T) { + t.Setenv("LOGOS_VAULT", t.TempDir()) + old := version + version = "0.5.7" + t.Cleanup(func() { version = old }) + out := captureStdout(t, func() { doctor(false, false, nil) }) + if !strings.Contains(out, "logos doctor --check-updates") { + t.Errorf("plain doctor never mentions --check-updates:\n%s", out) + } + if strings.Contains(out, "out of date") || strings.Contains(out, "latest release is") { + t.Errorf("plain doctor reported a release it had no business asking about:\n%s", out) + } +} + +func TestDoctorWithCheckUpdatesShowsTheVersionRow(t *testing.T) { + t.Setenv("LOGOS_VAULT", t.TempDir()) + opts, hits := fakeReleases(t, "v99.0.0", http.StatusOK) + old := version + version = "0.5.7" + t.Cleanup(func() { version = old }) + out := captureStdout(t, func() { doctor(false, false, opts) }) + if *hits != 1 || !strings.Contains(out, "logos 0.5.7 is out of date; the latest release is v99.0.0") { + t.Errorf("%d requests; output:\n%s", *hits, out) + } + if strings.Contains(out, "run `logos doctor --check-updates`") { + t.Errorf("hint shown although the check ran:\n%s", out) + } +} diff --git a/cmd/logos/egress.go b/cmd/logos/egress.go index 75d5d004..1dae052c 100644 --- a/cmd/logos/egress.go +++ b/cmd/logos/egress.go @@ -52,6 +52,9 @@ func doctorEgress() error { fmt.Println(" `logos update` — only when you run it") fmt.Printf(" %s, then the release download from github.com and *.githubusercontent.com\n", selfupdate.ReleasesFrom) + fmt.Println(" `logos doctor --check-updates` — only when you run it") + fmt.Printf(" %s, the same one request as `logos update --check`; a plain doctor never makes it\n", selfupdate.ReleasesFrom) + fmt.Println(" `logos setup` — only when you run it") fmt.Println(" the runtime's /api/pull, if you agree to download a model (the runtime fetches it)") fmt.Printf(" `claude plugin marketplace add %s`, which Claude Code fetches from GitHub\n", setup.PluginSource) diff --git a/cmd/logos/flag_menu.go b/cmd/logos/flag_menu.go index 5c3b786a..99bf5222 100644 --- a/cmd/logos/flag_menu.go +++ b/cmd/logos/flag_menu.go @@ -63,6 +63,7 @@ var flagMenus = map[string][]flagRow{ mode("", "health of the vault, index and hosts"), modifier("", "--verbose", "add runtimes and tiers"), modifier("", "--probe", "verify each model actually loads"), + modifier("", "--check-updates", "ask GitHub whether a newer release is out"), mode("--integration", "prove a host can reach logos"), mode("--report", "a paste-able bundle for a bug report"), mode("--egress", "every address logos can contact"), diff --git a/cmd/logos/flags.go b/cmd/logos/flags.go index 80d859a1..b5a491fc 100644 --- a/cmd/logos/flags.go +++ b/cmd/logos/flags.go @@ -87,7 +87,7 @@ var commandFlags = map[string]flagSpec{ "index": {bare: []string{"--watch", "--auto"}}, "migrate": {bare: []string{"--dry-run", "--yes", "-y"}}, "replay": {bare: []string{"--peek"}}, - "doctor": {bare: []string{"--verbose", "--probe", "--integration", "--report", "--egress"}}, + "doctor": {bare: []string{"--verbose", "--probe", "--check-updates", "--integration", "--report", "--egress"}}, "resume": {valued: []string{"--since"}, orDefault: []string{"--budget", "-b"}, bare: []string{"--json"}}, "sessions": {valued: []string{"--close"}}, "why": {numeric: []string{"--limit", "-n"}}, diff --git a/cmd/logos/help.go b/cmd/logos/help.go index ba008293..597b2608 100644 --- a/cmd/logos/help.go +++ b/cmd/logos/help.go @@ -184,7 +184,7 @@ SETUP AND DIAGNOSTICS logos setup --config [--vault DIR] merge logos into a config file at a location logos does not know by convention logos index [--watch] sync vault into the cache and embed - logos doctor [--verbose] [--probe] [--integration] [--report] [--egress] + logos doctor [--verbose] [--probe] [--check-updates] [--integration] [--report] [--egress] health of vault, index, hosts; --verbose adds runtimes and tiers; --integration proves a host can reach it; --report prints a paste-able bundle for a bug report; --egress lists every address logos can contact logos update [--check] check GitHub for a newer release, verify it, replace this binary diff --git a/cmd/logos/main.go b/cmd/logos/main.go index a890ab20..104fbfe2 100644 --- a/cmd/logos/main.go +++ b/cmd/logos/main.go @@ -15,6 +15,7 @@ import ( "github.com/Coder8124/logos/internal/index" "github.com/Coder8124/logos/internal/provider" "github.com/Coder8124/logos/internal/router" + "github.com/Coder8124/logos/internal/selfupdate" "github.com/Coder8124/logos/internal/setup" "github.com/Coder8124/logos/internal/vault" ) @@ -169,7 +170,11 @@ func run(cmd string, args []string) (err error) { err = doctorEgress() break } - err = doctor(hasFlag(args, "--probe"), hasFlag(args, "--verbose")) + var updates *selfupdate.Options + if hasFlag(args, "--check-updates") { + updates = &selfupdate.Options{} + } + err = doctor(hasFlag(args, "--probe"), hasFlag(args, "--verbose"), updates) case cmd == "key": err = keyCmd(args) case cmd == "update": diff --git a/cmd/logos/missing_vault_test.go b/cmd/logos/missing_vault_test.go index 01704a9d..935886c1 100644 --- a/cmd/logos/missing_vault_test.go +++ b/cmd/logos/missing_vault_test.go @@ -55,7 +55,7 @@ func TestDoctorExitsNonZeroWhenACheckFailed(t *testing.T) { t.Setenv("LOGOS_VAULT", filepath.Join(t.TempDir(), "not-a-vault")) var err error - out := captureStdout(t, func() { err = doctor(false, false) }) + out := captureStdout(t, func() { err = doctor(false, false, nil) }) if !strings.Contains(out, "FAILED") { t.Fatalf("expected a failing check to report on:\n%s", out) diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index a2945dd1..01b7bf62 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -4,8 +4,9 @@ // // This is the one place in the codebase that makes a network call on its own // initiative rather than because a user typed a command that needs one — and -// even here, only when the user typed `logos update` specifically. It never -// runs on a schedule, from doctor, or in the background, and the request it +// even here, only when the user typed `logos update`, or `logos doctor +// --check-updates`, which makes only the latest-release request. It never +// runs on a schedule, from a plain doctor, or in the background, and the request it // makes carries no vault path, no machine identifier, no telemetry: a bare GET // with a User-Agent naming this binary and nothing else. See Update's doc // comment for the verification chain that runs before anything is replaced.