Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cmd/logos/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmd/logos/doctor_lead_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/logos/doctor_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions cmd/logos/doctor_update.go
Original file line number Diff line number Diff line change
@@ -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)
}
108 changes: 108 additions & 0 deletions cmd/logos/doctor_update_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions cmd/logos/egress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions cmd/logos/flag_menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion cmd/logos/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
2 changes: 1 addition & 1 deletion cmd/logos/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ SETUP AND DIAGNOSTICS
logos setup --config <path> [--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
Expand Down
7 changes: 6 additions & 1 deletion cmd/logos/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion cmd/logos/missing_vault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions internal/selfupdate/selfupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading