diff --git a/internal/agent/cliupdate.go b/internal/agent/cliupdate.go new file mode 100644 index 00000000..52957d87 --- /dev/null +++ b/internal/agent/cliupdate.go @@ -0,0 +1,624 @@ +package agent + +// An agent's command-line program is kept up to date from the Agents page +// (#202): its version, the newest there is, and a click that updates it the +// way it was installed — with the CLI's own updater where it came from the +// vendor's installer (claude update, codex update, opencode upgrade), or by +// the package manager that owns it (npm, bun, pnpm, Homebrew). How it was +// installed is read from where its binary is; when that says nothing sure, +// the version is shown and nothing offered. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/proc" +) + +// CLI is what magpie knows of an agent's command-line program. +type CLI struct { + Version string `json:"version,omitempty"` + // Latest is the newest there is, from where it would be updated: + // Homebrew's for one Homebrew installed, npm's otherwise + Latest string `json:"latest,omitempty"` + // Via is what updates it: npm, bun, pnpm, brew, or self (its own + // updater); "" when magpie can't tell how it was installed + Via string `json:"via,omitempty"` + // Command is the update as a person would type it + Command string `json:"command,omitempty"` + // Update: Latest is after Version, and magpie knows how to get it + Update bool `json:"update,omitempty"` +} + +// cliSpec is where an agent's CLI is published. +type cliSpec struct { + npm []string // its packages on npm, the current one first + brew []string // its Homebrew formulae and casks + // self is the CLI's own updater, when the binary (real is where its + // links lead, with forward slashes) is the one its vendor's installer + // put there + self func(bin, real string) []string +} + +var cliSpecs = map[string]cliSpec{ + "claude": {npm: []string{"@anthropic-ai/claude-code"}, brew: []string{"claude-code"}, + self: func(bin, real string) []string { + // ~/.local/bin/claude → ~/.local/share/claude/versions/2.1.3; + // on Windows the binary itself, in ~/.local/bin + if strings.Contains(real, "/.local/share/claude/versions/") || strings.HasSuffix(strings.ToLower(real), "/.local/bin/claude.exe") { + return []string{bin, "update"} + } + return nil + }}, + "codex": {npm: []string{"@openai/codex"}, brew: []string{"codex"}, + self: func(bin, real string) []string { + // its standalone installer's ~/.codex/packages/standalone/releases/… + if strings.Contains(real, "/.codex/packages/standalone/") { + return []string{bin, "update"} + } + return nil + }}, + "gemini": {npm: []string{"@google/gemini-cli"}, brew: []string{"gemini-cli"}}, + "opencode": {npm: []string{"opencode-ai"}, brew: []string{"opencode"}, + self: func(bin, real string) []string { + if strings.Contains(real, "/.opencode/bin/") { + return []string{bin, "upgrade"} + } + return nil + }}, + "pi": {npm: []string{"@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"}}, + "omp": {npm: []string{"@oh-my-pi/pi-coding-agent"}}, + "copilot": {npm: []string{"@github/copilot"}, brew: []string{"copilot-cli"}}, + "crush": {npm: []string{"@charmland/crush"}, brew: []string{"crush"}}, + "cline": {npm: []string{"cline"}}, + "goose": {brew: []string{"block-goose-cli"}}, +} + +// updater is how an installed CLI is brought up to date. +type updater struct { + via string // npm, bun, pnpm, brew, self + pkg string // the npm package, or the Homebrew formula or cask + cask bool // pkg is a Homebrew cask + brew string // the brew that has it + cmd []string // the update + path string // a folder to put first on PATH for it (its npm's node) +} + +// shown is the update as a person would type it. +func (u *updater) shown() string { + parts := append([]string{filepath.Base(u.cmd[0])}, u.cmd[1:]...) + for i, p := range parts { + if strings.ContainsAny(p, " \t") { + parts[i] = strconv.Quote(p) + } + } + return strings.Join(parts, " ") +} + +var brewPath = regexp.MustCompile(`/(Cellar|Caskroom)/([^/]+)/`) + +// howInstalled says how the CLI at bin was installed, from where it is; +// nil when that says nothing sure. +func howInstalled(spec cliSpec, bin string) *updater { + real, err := filepath.EvalSymlinks(bin) + if err != nil { + real = bin + } + slash := filepath.ToSlash(real) + if spec.self != nil { + if c := spec.self(bin, slash); c != nil { + pkg := "" + if len(spec.npm) > 0 { + pkg = spec.npm[0] + } + return &updater{via: "self", pkg: pkg, cmd: c} + } + } + // a package manager's before Homebrew's: npm under Homebrew's node + // is npm's + for _, pkg := range spec.npm { + if u := fromNodeModules(pkg, bin, slash); u != nil { + return u + } + } + if m := brewPath.FindStringSubmatch(slash); m != nil { + if !contains(spec.brew, m[2]) { + return nil + } + prefix := slash[:strings.Index(slash, m[0])] + brew := filepath.FromSlash(prefix + "/bin/brew") + if !isFile(brew) { + if brew, err = exec.LookPath("brew"); err != nil { + return nil + } + } + u := &updater{via: "brew", pkg: m[2], cask: m[1] == "Caskroom", brew: brew} + u.cmd = []string{brew, "upgrade", m[2]} + if u.cask { + u.cmd = []string{brew, "upgrade", "--cask", m[2]} + } + return u + } + return nil +} + +// fromNodeModules is the updater of a CLI the package pkg installed in a +// global node_modules: the binary leads there (npm's and bun's links), or +// is a shim that names it (npm's .cmd on Windows, pnpm's scripts). +func fromNodeModules(pkg, bin, real string) *updater { + mark := "/node_modules/" + pkg + "/" + modules := "" + if i := strings.Index(real, mark); i >= 0 { + modules = real[:i] + "/node_modules" + } else if shimNames(bin, pkg) { + modules = filepath.ToSlash(filepath.Join(filepath.Dir(bin), "node_modules")) + if !isDir(filepath.Join(filepath.FromSlash(modules), filepath.FromSlash(pkg))) { + modules = "" // pnpm's shims name a store elsewhere + } + } else { + return nil + } + lower := strings.ToLower(real + "|" + filepath.ToSlash(bin)) + switch { + case strings.Contains(lower, "/.bun/"): + bun := filepath.Join(filepath.Dir(bin), exe("bun")) + if !isFile(bun) { + var err error + if bun, err = exec.LookPath("bun"); err != nil { + return nil + } + } + return &updater{via: "bun", pkg: pkg, cmd: []string{bun, "add", "-g", pkg + "@latest"}} + case strings.Contains(lower, "/pnpm/") || strings.Contains(lower, "/.pnpm/"): + pnpm, err := exec.LookPath("pnpm") + if err != nil { + return nil + } + return &updater{via: "pnpm", pkg: pkg, cmd: []string{pnpm, "add", "-g", pkg + "@latest"}} + case strings.Contains(lower, "/.volta/") || strings.Contains(lower, "/yarn/") || strings.Contains(lower, "/.yarn/"): + return nil // installed by a manager of its own, which magpie leaves to it + } + if modules == "" { + return nil + } + // the prefix npm installed it under: …/lib/node_modules on a Mac or + // Linux, …\node_modules itself on Windows + m := filepath.FromSlash(modules) + prefix := filepath.Dir(m) + npm := filepath.Join(prefix, "npm.cmd") + if runtime.GOOS != "windows" { + if filepath.Base(prefix) != "lib" { + return nil // not a global install + } + prefix = filepath.Dir(prefix) + npm = filepath.Join(prefix, "bin", "npm") + } + if !writable(m) { + return nil // the system's (/usr/lib): npm would want sudo + } + u := &updater{via: "npm", pkg: pkg} + if isFile(npm) { + u.path = filepath.Dir(npm) // its own node, not another on PATH + } else { + var err error + if npm, err = exec.LookPath("npm"); err != nil { + return nil + } + } + u.cmd = []string{npm, "install", "-g", "--prefix", prefix, pkg + "@latest"} + return u +} + +// shimNames says whether bin is a small script naming pkg's folder. +func shimNames(bin, pkg string) bool { + st, err := os.Stat(bin) + if err != nil || st.Size() > 64<<10 { + return false + } + b, err := os.ReadFile(bin) + if err != nil { + return false + } + return strings.Contains(strings.ReplaceAll(string(b), `\`, "/"), "node_modules/"+pkg+"/") +} + +func exe(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + +func isFile(p string) bool { st, err := os.Stat(p); return err == nil && !st.IsDir() } +func isDir(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() } + +// ---- versions -------------------------------------------------------------- + +var versionRe = regexp.MustCompile(`(?:^|[^\w.])v?(\d+\.\d+(?:\.\d+)*(?:-[0-9A-Za-z][0-9A-Za-z.]*)?)`) + +// parseVersion is the first version in what a CLI printed. +func parseVersion(out string) string { + if m := versionRe.FindStringSubmatch(ansiRe.ReplaceAllString(out, " ")); m != nil { + return strings.TrimRight(m[1], ".") + } + return "" +} + +// Newer says whether version a is after b: by their numbers, then a release +// after its pre-releases (1.0.87 after 1.0.87-0). +func Newer(a, b string) bool { + a, b = strings.TrimPrefix(a, "v"), strings.TrimPrefix(b, "v") + an, apre, _ := strings.Cut(a, "-") + bn, bpre, _ := strings.Cut(b, "-") + x, y := strings.Split(an, "."), strings.Split(bn, ".") + for i := 0; i < len(x) || i < len(y); i++ { + var p, q int + if i < len(x) { + p, _ = strconv.Atoi(x[i]) + } + if i < len(y) { + q, _ = strconv.Atoi(y[i]) + } + if p != q { + return p > q + } + } + switch { + case apre == bpre: + return false + case apre == "": + return true + case bpre == "": + return false + } + return preNewer(apre, bpre) +} + +// preNewer orders pre-release tags part by part, numbers as numbers. +func preNewer(a, b string) bool { + x, y := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(x) && i < len(y); i++ { + p, e1 := strconv.Atoi(x[i]) + q, e2 := strconv.Atoi(y[i]) + switch { + case e1 == nil && e2 == nil: + if p != q { + return p > q + } + case x[i] != y[i]: + return x[i] > y[i] + } + } + return len(x) > len(y) +} + +// memo keeps an answer that took a process or a request to get, with one +// asking at a time. +type memo struct { + mu sync.Mutex + m map[string]*memoEntry +} + +type memoEntry struct { + v string + stamp string + until time.Time + ready chan struct{} +} + +// get is the answer for key, asked again once stamp changes or it's out of +// date: good for ok, a failure for fail (the answer before kept meanwhile). +func (m *memo) get(key, stamp string, ok, fail time.Duration, ask func() (string, error)) string { + m.mu.Lock() + if m.m == nil { + m.m = map[string]*memoEntry{} + } + e := m.m[key] + if e != nil && e.stamp == stamp && (e.ready != nil || time.Now().Before(e.until)) { + ready := e.ready + m.mu.Unlock() + if ready != nil { + <-ready + m.mu.Lock() + v := e.v + m.mu.Unlock() + return v + } + return e.v + } + old := "" + if e != nil && e.stamp == stamp { + old = e.v + } + e = &memoEntry{v: old, stamp: stamp, ready: make(chan struct{})} + m.m[key] = e + m.mu.Unlock() + v, err := ask() + m.mu.Lock() + if err == nil { + e.v, e.until = v, time.Now().Add(ok) + } else { + e.until = time.Now().Add(fail) + } + close(e.ready) + e.ready = nil + v = e.v + m.mu.Unlock() + return v +} + +func (m *memo) forget(key string) { + m.mu.Lock() + if e := m.m[key]; e != nil && e.ready == nil { + delete(m.m, key) + } + m.mu.Unlock() +} + +var versions, latests memo + +// installedVersion is what the CLI at bin says its version is, asked again +// only once the binary changes. +func installedVersion(bin string) string { + real, err := filepath.EvalSymlinks(bin) + if err != nil { + real = bin + } + st, err := os.Stat(real) + if err != nil { + return "" + } + stamp := fmt.Sprint(real, st.Size(), st.ModTime().UnixNano()) + return versions.get(bin, stamp, 24*time.Hour, 10*time.Minute, func() (string, error) { + v := parseVersion(runVersion(bin)) + if v == "" { + return "", errors.New("no version") + } + return v, nil + }) +} + +// runVersion runs bin --version with nothing on its stdin; a var so tests +// can fake it. +var runVersion = func(bin string) string { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := proc.CommandContext(ctx, bin, "--version") + cmd.Stdin = nil // /dev/null: one that would ask something gets nothing + cmd.Env = append(os.Environ(), "NO_COLOR=1") + out, _ := cmd.CombinedOutput() + return string(out) +} + +// latestVersion is the newest version where u would update from. +func latestVersion(u *updater) string { + if u.via == "brew" { + return latests.get("brew:"+u.pkg, "", 6*time.Hour, 15*time.Minute, func() (string, error) { return brewLatest(u) }) + } + if u.pkg == "" { + return "" + } + return latests.get("npm:"+u.pkg, "", 6*time.Hour, 15*time.Minute, func() (string, error) { return npmLatest(u.pkg) }) +} + +// npmRegistry is where npm's packages are read; a var so tests can serve one. +var npmRegistry = "https://registry.npmjs.org/" + +func npmLatest(pkg string) (string, error) { + c := &http.Client{Timeout: 8 * time.Second} + req, _ := http.NewRequest("GET", npmRegistry+pkg+"/latest", nil) + req.Header.Set("User-Agent", "magpie") + req.Header.Set("Accept", "application/json") + resp, err := c.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("npm answered %s", resp.Status) + } + var r struct{ Version string } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&r); err != nil { + return "", err + } + if r.Version == "" { + return "", errors.New("no version") + } + return r.Version, nil +} + +// brewLatest is the version Homebrew has for u, as it last updated itself: +// what brew upgrade would install, or newer once it updates first. +var brewLatest = func(u *updater) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + args := []string{"info", "--json=v2", u.pkg} + if u.cask { + args = []string{"info", "--json=v2", "--cask", u.pkg} + } + cmd := proc.CommandContext(ctx, u.brew, args...) + cmd.Stdin = nil + cmd.Env = append(os.Environ(), "HOMEBREW_NO_AUTO_UPDATE=1", "HOMEBREW_NO_ENV_HINTS=1") + out, err := cmd.Output() + if err != nil { + return "", err + } + var r struct { + Formulae []struct { + Versions struct{ Stable string } + } + Casks []struct{ Version string } + } + if err := json.Unmarshal(out, &r); err != nil { + return "", err + } + v := "" + if len(r.Formulae) > 0 { + v = r.Formulae[0].Versions.Stable + } else if len(r.Casks) > 0 { + v, _, _ = strings.Cut(r.Casks[0].Version, ",") // 1.2.3,build + } + if v = parseVersion(" " + v); v == "" { + return "", errors.New("no version") + } + return v, nil +} + +// cliBin is where the agent's CLI is, "" when it has none magpie knows or +// it isn't on PATH. +func (a *Agent) cliBin() (string, cliSpec) { + spec, ok := cliSpecs[a.ID] + if !ok || a.WSL != "" || a.Bin == "" { + return "", spec + } + bin, err := exec.LookPath(a.Bin) + if err != nil { + return "", spec + } + if abs, err := filepath.Abs(bin); err == nil { + bin = abs + } + return bin, spec +} + +// CLI is the agent's CLI: its version, the newest, and whether magpie can +// update it. ok is false for an agent without a CLI magpie knows. +func (a *Agent) CLI() (c CLI, ok bool) { + bin, spec := a.cliBin() + if bin == "" { + return CLI{}, false + } + c.Version = installedVersion(bin) + u := howInstalled(spec, bin) + if u == nil { + return c, true + } + c.Via, c.Command = u.via, u.shown() + c.Latest = latestVersion(u) + c.Update = c.Version != "" && c.Latest != "" && Newer(c.Latest, c.Version) + return c, true +} + +// CLIs is every detected agent's CLI, as far as it is known within wait; +// pending says some are still being asked, and will be ready next time. +func CLIs(wait time.Duration) (out map[string]CLI, pending bool) { + var mu sync.Mutex + var wg sync.WaitGroup + out = map[string]CLI{} + for _, a := range Detected() { + if _, ok := cliSpecs[a.ID]; !ok || a.WSL != "" { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + if c, ok := a.CLI(); ok { + mu.Lock() + out[a.ID] = c + mu.Unlock() + } + }() + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(wait): + pending = true + } + mu.Lock() + defer mu.Unlock() + snap := make(map[string]CLI, len(out)) + for k, v := range out { + snap[k] = v + } + return snap, pending +} + +var updating sync.Map // agent id → an update running + +// runUpdate runs an update with nothing to answer it; a var so tests never +// run a real one. +var runUpdate = func(ctx context.Context, u *updater) ([]byte, error) { + cmd := proc.CommandContext(ctx, u.cmd[0], u.cmd[1:]...) + cmd.Stdin = nil + cmd.Dir, _ = os.UserHomeDir() + env := netproxy.Env(os.Environ()) + if u.path != "" { + for i, kv := range env { + if k, v, _ := strings.Cut(kv, "="); strings.EqualFold(k, "PATH") { + env[i] = k + "=" + u.path + string(os.PathListSeparator) + v + } + } + } + cmd.Env = append(env, "NONINTERACTIVE=1", "HOMEBREW_NO_ENV_HINTS=1", "NO_COLOR=1") + return cmd.CombinedOutput() +} + +// updateTimeout bounds an update: a download, or Homebrew updating itself first. +var updateTimeout = 10 * time.Minute + +// UpdateCLI brings the agent's CLI up to date the way it was installed, and +// says what it is afterwards. +func (a *Agent) UpdateCLI() (CLI, error) { + bin, spec := a.cliBin() + if bin == "" { + return CLI{}, fmt.Errorf("magpie can't find %s's CLI", a.Name) + } + u := howInstalled(spec, bin) + if u == nil { + return CLI{}, fmt.Errorf("magpie can't tell how %s was installed at %s — update it the way you installed it", a.Name, bin) + } + if _, busy := updating.LoadOrStore(a.ID, true); busy { + return CLI{}, fmt.Errorf("%s is already being updated", a.Name) + } + defer updating.Delete(a.ID) + before := installedVersion(bin) + ctx, cancel := context.WithTimeout(context.Background(), updateTimeout) + defer cancel() + out, err := runUpdate(ctx, u) + versions.forget(bin) + // brew upgrade updates Homebrew first, which may know a newer one + latests.forget("brew:" + u.pkg) + c, _ := a.CLI() + if err != nil { + msg := lastLines(string(out), 3) + if msg == "" || errors.Is(ctx.Err(), context.DeadlineExceeded) { + msg = strings.TrimSpace(msg + " " + err.Error()) + } + return c, fmt.Errorf("%s: %s", u.shown(), msg) + } + if c.Update && c.Version == before { + return c, fmt.Errorf("%s is still %s after %s; %s is out", a.Name, c.Version, u.shown(), c.Latest) + } + return c, nil +} + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + +// lastLines is the last n lines of out with something on them, without +// their colours. +func lastLines(out string, n int) string { + var keep []string + lines := strings.Split(ansiRe.ReplaceAllString(out, ""), "\n") + for i := len(lines) - 1; i >= 0 && len(keep) < n; i-- { + if l := strings.TrimSpace(lines[i]); l != "" { + keep = append([]string{l}, keep...) + } + } + return strings.Join(keep, " · ") +} diff --git a/internal/agent/cliupdate_test.go b/internal/agent/cliupdate_test.go new file mode 100644 index 00000000..a7a56abb --- /dev/null +++ b/internal/agent/cliupdate_test.go @@ -0,0 +1,286 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestNewerVersions(t *testing.T) { + for _, c := range []struct { + a, b string + want bool + }{ + {"2.1.3", "2.1.2", true}, + {"2.1.2", "2.1.3", false}, + {"2.1.3", "2.1.3", false}, + {"0.10.0", "0.9.9", true}, + {"1.0", "1.0.0", false}, + {"1.0.1", "1.0", true}, + {"v1.2.0", "1.1.9", true}, + {"1.0.89", "1.0.87-0", true}, + {"1.0.87", "1.0.87-0", true}, + {"1.0.87-0", "1.0.87", false}, + {"1.0.87-beta.10", "1.0.87-beta.9", true}, + {"1.0.87-beta.2", "1.0.87-alpha.9", true}, + {"0.74.0", "0.3.0", true}, + } { + if got := Newer(c.a, c.b); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +func TestParseCLIVersion(t *testing.T) { + for out, want := range map[string]string{ + "2.1.284 (Claude Code)\n": "2.1.284", + "codex-cli 0.155.1": "0.155.1", + "0.61.0": "0.61.0", + "GitHub Copilot CLI 1.0.87-0.\nRun 'copilot update' to check for updates.": "1.0.87-0", + "crush version v0.3.0": "0.3.0", + "omp/16.3.5": "16.3.5", + "\x1b[1m1.18.32\x1b[0m": "1.18.32", + "command not found": "", + "": "", + } { + if got := parseVersion(out); got != want { + t.Errorf("parseVersion(%q) = %q, want %q", out, got, want) + } + } +} + +// file makes a file (and its folders) in a temp tree, executable. +func file(t *testing.T, path, body string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// link makes a symlink at path to target, as npm, bun, Homebrew and the +// vendors' installers do. +func link(t *testing.T, target, path string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + return path +} + +func TestHowInstalled(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks; the Windows shim is TestHowInstalledWindowsShim") + } + tmp, _ := filepath.EvalSymlinks(t.TempDir()) // /var is /private/var on a Mac + // no brew, npm, bun or pnpm of the machine's: only the fakes here + fake := filepath.Join(tmp, "fakebin") + t.Setenv("PATH", fake) + + // npm, in a prefix of its own with its own npm + prefix := filepath.Join(tmp, "node") + js := file(t, filepath.Join(prefix, "lib/node_modules/@google/gemini-cli/bundle/gemini.js"), "#!/bin/sh\n") + npm := file(t, filepath.Join(prefix, "bin/npm"), "#!/bin/sh\n") + gem := link(t, "../lib/node_modules/@google/gemini-cli/bundle/gemini.js", filepath.Join(prefix, "bin/gemini")) + _ = js + u := howInstalled(cliSpecs["gemini"], gem) + if u == nil || u.via != "npm" || u.path != filepath.Join(prefix, "bin") { + t.Fatalf("npm: %+v", u) + } + if want := []string{npm, "install", "-g", "--prefix", prefix, "@google/gemini-cli@latest"}; !reflect.DeepEqual(u.cmd, want) { + t.Errorf("npm: %q, want %q", u.cmd, want) + } + if u.shown() != "npm install -g --prefix "+prefix+" @google/gemini-cli@latest" { + t.Errorf("npm shown: %q", u.shown()) + } + // another agent's package in the same place is not the agent's + if u := howInstalled(cliSpecs["codex"], gem); u != nil { + t.Errorf("gemini's package taken for codex: %+v", u) + } + // npm with no npm of its own takes the one on PATH, or none + prefix2 := filepath.Join(tmp, "node2") + file(t, filepath.Join(prefix2, "lib/node_modules/@github/copilot/npm-loader.js"), "") + cop := link(t, "../lib/node_modules/@github/copilot/npm-loader.js", filepath.Join(prefix2, "bin/copilot")) + if u := howInstalled(cliSpecs["copilot"], cop); u != nil { + t.Errorf("no npm anywhere, yet: %+v", u) + } + pathNpm := file(t, filepath.Join(fake, "npm"), "#!/bin/sh\n") + if u := howInstalled(cliSpecs["copilot"], cop); u == nil || u.cmd[0] != pathNpm || u.path != "" { + t.Errorf("npm on PATH: %+v", u) + } + // a node_modules that isn't a global prefix's (…/lib/node_modules) + file(t, filepath.Join(tmp, "proj/node_modules/cline/bin/cline.js"), "") + if u := howInstalled(cliSpecs["cline"], link(t, filepath.Join(tmp, "proj/node_modules/cline/bin/cline.js"), filepath.Join(tmp, "proj/bin/cline"))); u != nil { + t.Errorf("a project's node_modules: %+v", u) + } + // one this user can't write to (the system's) is left alone + if os.Getuid() != 0 { + sys := filepath.Join(tmp, "usr") + file(t, filepath.Join(sys, "lib/node_modules/cline/bin/cline.js"), "") + cl := link(t, "../lib/node_modules/cline/bin/cline.js", filepath.Join(sys, "bin/cline")) + os.Chmod(filepath.Join(sys, "lib/node_modules"), 0o555) + t.Cleanup(func() { os.Chmod(filepath.Join(sys, "lib/node_modules"), 0o755) }) + if u := howInstalled(cliSpecs["cline"], cl); u != nil { + t.Errorf("a read-only prefix: %+v", u) + } + } + + // bun's global, with bun beside the link + home := filepath.Join(tmp, "home") + file(t, filepath.Join(home, ".bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js"), "") + bun := file(t, filepath.Join(home, ".bun/bin/bun"), "") + omp := link(t, "../install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js", filepath.Join(home, ".bun/bin/omp")) + if u := howInstalled(cliSpecs["omp"], omp); u == nil || !reflect.DeepEqual(u.cmd, []string{bun, "add", "-g", "@oh-my-pi/pi-coding-agent@latest"}) { + t.Errorf("bun: %+v", u) + } + + // pnpm's shim, a script naming its store + shim := file(t, filepath.Join(home, "Library/pnpm/claude"), "#!/bin/sh\nexec node \""+home+"/Library/pnpm/global/5/.pnpm/@anthropic-ai+claude-code@2.1.0/node_modules/@anthropic-ai/claude-code/cli.js\" \"$@\"\n") + if u := howInstalled(cliSpecs["claude"], shim); u != nil { + t.Errorf("pnpm without pnpm: %+v", u) + } + pnpm := file(t, filepath.Join(fake, "pnpm"), "#!/bin/sh\n") + if u := howInstalled(cliSpecs["claude"], shim); u == nil || !reflect.DeepEqual(u.cmd, []string{pnpm, "add", "-g", "@anthropic-ai/claude-code@latest"}) { + t.Errorf("pnpm: %+v", u) + } + + // Homebrew: a formula, a cask, and a formula not the agent's + brewDir := filepath.Join(tmp, "homebrew") + brew := file(t, filepath.Join(brewDir, "bin/brew"), "#!/bin/sh\n") + file(t, filepath.Join(brewDir, "Cellar/crush/0.3.0/bin/crush"), "") + crush := link(t, "../Cellar/crush/0.3.0/bin/crush", filepath.Join(brewDir, "bin/crush")) + if u := howInstalled(cliSpecs["crush"], crush); u == nil || u.via != "brew" || u.cask || !reflect.DeepEqual(u.cmd, []string{brew, "upgrade", "crush"}) { + t.Errorf("brew formula: %+v", u) + } + file(t, filepath.Join(brewDir, "Caskroom/claude-code/2.1.0/claude"), "") + cc := link(t, "../Caskroom/claude-code/2.1.0/claude", filepath.Join(brewDir, "bin/claude")) + if u := howInstalled(cliSpecs["claude"], cc); u == nil || !u.cask || !reflect.DeepEqual(u.cmd, []string{brew, "upgrade", "--cask", "claude-code"}) { + t.Errorf("brew cask: %+v", u) + } + file(t, filepath.Join(brewDir, "Cellar/something/1.0/bin/gemini"), "") + if u := howInstalled(cliSpecs["gemini"], link(t, "../Cellar/something/1.0/bin/gemini", filepath.Join(brewDir, "bin/gemini"))); u != nil { + t.Errorf("another formula: %+v", u) + } + + // the vendors' own installers, updated with the CLI's own updater + file(t, filepath.Join(home, ".local/share/claude/versions/2.1.284"), "") + claude := link(t, filepath.Join(home, ".local/share/claude/versions/2.1.284"), filepath.Join(home, ".local/bin/claude")) + if u := howInstalled(cliSpecs["claude"], claude); u == nil || u.via != "self" || u.pkg != "@anthropic-ai/claude-code" || !reflect.DeepEqual(u.cmd, []string{claude, "update"}) { + t.Errorf("claude's installer: %+v", u) + } + file(t, filepath.Join(home, ".codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex"), "") + codex := link(t, filepath.Join(home, ".codex/packages/standalone/releases/0.155.1-aarch64-apple-darwin/bin/codex"), filepath.Join(home, ".local/bin/codex")) + if u := howInstalled(cliSpecs["codex"], codex); u == nil || u.via != "self" || !reflect.DeepEqual(u.cmd, []string{codex, "update"}) { + t.Errorf("codex's installer: %+v", u) + } + oc := file(t, filepath.Join(home, ".opencode/bin/opencode"), "") + if u := howInstalled(cliSpecs["opencode"], oc); u == nil || u.shown() != "opencode upgrade" { + t.Errorf("opencode's installer: %+v", u) + } + + // a binary that says nothing of how it got there: the version only + if u := howInstalled(cliSpecs["claude"], file(t, filepath.Join(tmp, "opt/bin/claude"), "\x7fELF")); u != nil { + t.Errorf("unknown: %+v", u) + } + // an agent with no updater of its own, from its vendor's folder + if u := howInstalled(cliSpecs["gemini"], file(t, filepath.Join(home, ".local/bin/gemini"), "")); u != nil { + t.Errorf("gemini in ~/.local/bin: %+v", u) + } +} + +// npm's shim on Windows is a .cmd beside node_modules, naming it. +func TestHowInstalledWindowsShim(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows' npm layout") + } + dir := filepath.Join(t.TempDir(), "npm") + file(t, filepath.Join(dir, `node_modules\@anthropic-ai\claude-code\cli.js`), "") + npm := file(t, filepath.Join(dir, "npm.cmd"), "") + shim := file(t, filepath.Join(dir, "claude.cmd"), "@ECHO off\r\n\"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js\" %*\r\n") + u := howInstalled(cliSpecs["claude"], shim) + if u == nil || !reflect.DeepEqual(u.cmd, []string{npm, "install", "-g", "--prefix", dir, "@anthropic-ai/claude-code@latest"}) { + t.Fatalf("windows shim: %+v", u) + } + // the native claude.exe in ~/.local/bin updates itself + exe := file(t, filepath.Join(t.TempDir(), `.local\bin\claude.exe`), "MZ") + if u := howInstalled(cliSpecs["claude"], exe); u == nil || u.via != "self" { + t.Errorf("claude.exe: %+v", u) + } +} + +// The whole of it with a fake CLI, a fake registry and a fake update: +// nothing real is installed or updated. +func TestUpdateCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a shell script as the CLI") + } + tmp, _ := filepath.EvalSymlinks(t.TempDir()) + prefix := filepath.Join(tmp, "node") + verFile := filepath.Join(tmp, "version") + os.WriteFile(verFile, []byte("0.60.0\n"), 0o644) + file(t, filepath.Join(prefix, "lib/node_modules/@google/gemini-cli/bundle/gemini.js"), "#!/bin/sh\n/bin/cat "+verFile+"\n") + file(t, filepath.Join(prefix, "bin/npm"), "#!/bin/sh\nexit 1\n") + link(t, "../lib/node_modules/@google/gemini-cli/bundle/gemini.js", filepath.Join(prefix, "bin/gemini")) + t.Setenv("PATH", filepath.Join(prefix, "bin")) + + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/@google/gemini-cli/latest" { + http.NotFound(w, r) + return + } + w.Write([]byte(`{"name":"@google/gemini-cli","version":"0.61.0"}`)) + })) + defer reg.Close() + oldReg, oldRun := npmRegistry, runUpdate + npmRegistry = reg.URL + "/" + versions, latests = memo{}, memo{} + var ran [][]string + bump := "0.61.0" + runUpdate = func(_ context.Context, u *updater) ([]byte, error) { + ran = append(ran, u.cmd) + os.WriteFile(verFile, []byte(bump+"\n"), 0o644) + return []byte("added 1 package"), nil + } + t.Cleanup(func() { npmRegistry, runUpdate = oldReg, oldRun; versions, latests = memo{}, memo{} }) + + a := &Agent{ID: "gemini", Name: "Gemini CLI", Bin: "gemini"} + c, ok := a.CLI() + if !ok || c.Version != "0.60.0" || c.Latest != "0.61.0" || !c.Update || c.Via != "npm" { + t.Fatalf("before: %+v", c) + } + c, err := a.UpdateCLI() + if err != nil || c.Version != "0.61.0" || c.Update { + t.Fatalf("after: %+v, %v", c, err) + } + if len(ran) != 1 || !strings.HasSuffix(strings.Join(ran[0], " "), "install -g --prefix "+prefix+" @google/gemini-cli@latest") { + t.Errorf("ran %q", ran) + } + + // an update that leaves it as it was says so + os.WriteFile(verFile, []byte("0.59.0\n"), 0o644) + versions.forget(filepath.Join(prefix, "bin/gemini")) + bump = "0.59.0" + if _, err := a.UpdateCLI(); err == nil || !strings.Contains(err.Error(), "still 0.59.0") { + t.Errorf("no change: %v", err) + } + + // an agent magpie has no CLI for, or one in WSL, has nothing + if _, ok := (&Agent{ID: "alma"}).CLI(); ok { + t.Error("alma has a CLI") + } + if _, ok := (&Agent{ID: "codex", Bin: "codex", WSL: "Ubuntu"}).CLI(); ok { + t.Error("a WSL codex has a CLI here") + } +} diff --git a/internal/agent/writable_other.go b/internal/agent/writable_other.go new file mode 100644 index 00000000..80692a53 --- /dev/null +++ b/internal/agent/writable_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package agent + +import "golang.org/x/sys/unix" + +// writable says whether this user can write in dir. +func writable(dir string) bool { return unix.Access(dir, unix.W_OK) == nil } diff --git a/internal/agent/writable_windows.go b/internal/agent/writable_windows.go new file mode 100644 index 00000000..00978a33 --- /dev/null +++ b/internal/agent/writable_windows.go @@ -0,0 +1,5 @@ +package agent + +// writable says whether this user can write in dir: on Windows npm's +// global folder is the user's own (%APPDATA%\npm). +func writable(string) bool { return true } diff --git a/internal/gui/api.go b/internal/gui/api.go index 0bced8b3..59957294 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -264,6 +264,28 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, out) }) + // the agents' CLIs: their versions and the newest (#202), as far as + // they're known within a moment — the rest are asked on meanwhile, and + // pending says to ask again soon + mux.HandleFunc("GET /api/agents/cli", func(rw http.ResponseWriter, r *http.Request) { + clis, pending := agent.CLIs(3 * time.Second) + writeJSON(rw, map[string]any{"agents": clis, "pending": pending}) + }) + // updates one the way it was installed; what it is afterwards comes + // back with an error too + mux.HandleFunc("POST /api/agents/cli/{id}", func(rw http.ResponseWriter, r *http.Request) { + a, err := agent.Find(r.PathValue("id")) + if err != nil { + fail(rw, err) + return + } + c, err := a.UpdateCLI() + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, c) + }) mux.HandleFunc("POST /api/agents/{action}/{id}", func(rw http.ResponseWriter, r *http.Request) { a, err := agent.Find(r.PathValue("id")) if err != nil { diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 8b12935f..728e51d3 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -310,6 +310,21 @@ body.window .ag-handle { width: 28px; height: 28px; } .ag-fix:focus-visible { outline: 1.5px solid var(--drift); outline-offset: 1px; } .ag-fix.busy { pointer-events: none; opacity: .7; } .ag-fix.busy svg { animation: spin .8s linear infinite; } +/* the agent's CLI on its name's line (#202): its version faint, and a quiet + pill that updates it when a newer one is out — the row keeps its height */ +body.window .row.agent .who { display: flex; align-items: center; gap: 7px; } +body.window .row.agent .who .name { flex: 0 1 auto; } +.ag-cli { display: inline-flex; align-items: center; gap: 6px; flex: none; min-width: 0; } +.ag-cli:empty { display: none; } +.ag-ver { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; cursor: default; } +.ag-up { display: inline-flex; align-items: center; gap: 4px; flex: none; height: 20px; padding: 0 8px 0 6px; border: 1px solid var(--line); border-radius: 999px; background: var(--card); color: var(--fg-2); font: inherit; font-size: 11.5px; font-weight: 500; line-height: 1; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s, transform .12s; } +.ag-up:hover { background: var(--pill); color: var(--fg); } +.ag-up:active { transform: scale(.96); } +.ag-up:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 1px; } +.ag-up svg { color: var(--muted); } +.ag-up.busy { pointer-events: none; color: var(--muted); } +.ag-up.busy svg { animation: spin .8s linear infinite; } +@media (prefers-reduced-motion: reduce) { .ag-up.busy svg { animation: none; } } /* the panel's name column has no room for it: there it is a badge on the agent's logo, the name and the columns left as every other row's */ body.panel .row.drifted { position: relative; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 28dbe3fb..a9a060f9 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -339,6 +339,9 @@ function renderAgents() { row.classList.add("drifted"); who.append(driftFix(a)); } + // the CLI's version, and an update when one is out (#202); the panel's + // name column has no room for it + if (mode !== "panel") who.append(cliTag(a)); row.append(agentHandle(a, row, inFold), who); if (sum) row.append(sum, ...(openBox ? [openBox] : [])); else row.append(fields); @@ -492,6 +495,87 @@ async function reapplyAgent(a, btn) { } } +// ---------- the agents' CLIs (#202) ---------- +// Each agent's CLI shows its version after its name, faint; when a newer one +// is out and magpie knows how the CLI was installed (its own updater, npm, +// bun, pnpm, Homebrew), a pill beside it updates it. The versions come after +// the rows are drawn, never holding them up; one magpie can't tell how it +// was installed shows its version alone. + +let cliInfo = {}; // agent id → { version, latest, via, command, update } +const cliBusy = new Set(); // the ones being updated now +const CLI_UP = "M8 2.5v8M4.5 7 8 10.5 11.5 7M3.5 13.5h9"; +const CLI_SPIN = "M13.5 8a5.5 5.5 0 1 1-5.5-5.5"; + +function cliTag(a) { + const box = el("span", "ag-cli"); + const c = cliInfo[a.id]; + if (!c?.version) return box; + const v = el("span", "ag-ver", c.version); + v.title = !c.via ? t("{agent} {v} · magpie can't tell how it was installed — update it the way you installed it", { agent: a.name, v: c.version }) + : c.update ? t("{agent} {v} is installed · {latest} is out", { agent: a.name, v: c.version, latest: c.latest }) + : t("{agent} {v} · up to date", { agent: a.name, v: c.version }); + box.append(v); + if (c.update || cliBusy.has(a.id)) { + const b = el("button", "ag-up"); + b.type = "button"; + b.title = t("Updates with {cmd}", { cmd: c.command }); + paintCLIButton(b, c, cliBusy.has(a.id)); + b.onclick = (e) => { e.stopPropagation(); updateCLI(a, b); }; + box.append(b); + } + return box; +} + +function paintCLIButton(b, c, busy) { + b.classList.toggle("busy", busy); + b.setAttribute("aria-busy", String(busy)); + b.replaceChildren(svg(busy ? CLI_SPIN : CLI_UP, 11, 1.8), el("span", "", busy ? t("Updating…") : t("Update to {v}", { v: c.latest }))); +} + +// paintCLI draws an agent's CLI again where it is, the row left as it is +function paintCLI(id) { + const a = state?.agents?.find((x) => x.id === id); + if (!a) return; + for (const old of document.querySelectorAll(`#agents .row.agent[data-id="${CSS.escape(id)}"] .ag-cli`)) old.replaceWith(cliTag(a)); +} + +let cliLoading = null; +async function loadCLIs(again = 0) { + if (mode === "panel" || cliLoading) return; + cliLoading = (async () => { + let r; + try { r = await api("agents/cli"); } catch { return; } // it just isn't shown + const next = r?.agents || {}; + const was = cliInfo; + cliInfo = next; + for (const id of new Set([...Object.keys(was), ...Object.keys(next)])) { + if (JSON.stringify(was[id]) !== JSON.stringify(next[id])) paintCLI(id); + } + // some were still being asked: they are ready in a moment + if (r?.pending && again < 3) setTimeout(() => loadCLIs(again + 1), 4000); + })(); + try { await cliLoading; } finally { cliLoading = null; } +} + +async function updateCLI(a, btn) { + if (cliBusy.has(a.id)) return; + cliBusy.add(a.id); + paintCLIButton(btn, cliInfo[a.id] || {}, true); // in place: what was clicked stays + try { + const c = await api("agents/cli/" + encodeURIComponent(a.id), {}); + cliInfo[a.id] = c; + status(t("{agent} updated to {v}", { agent: a.name, v: c.version }), "ok"); + } catch (e) { + status(e.message, "err", 12000); + cliBusy.delete(a.id); + await loadCLIs(); // what it is now + } finally { + cliBusy.delete(a.id); + paintCLI(a.id); + } +} + // ---------- the agents' order, and the ones put away ---------- // Kept in magpie's own settings (agentOrder, agentsHidden, agentsShown), // never in an agent's files. An agent the order doesn't name — one @@ -867,6 +951,7 @@ async function load() { if (applyPrefs(state.settings) && view === "library") window.loadLibrary?.(); tintPanel(); renderAgents(); + loadCLIs(); // after the rows, never holding them up if (mode === "panel") { renderPanelQuota(); loadQuotas(); } // an open provider editor is someone typing: coming back to the window // must not rebuild it under them diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index b8eb679d..92b30c8e 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -45,6 +45,12 @@ const I18N = { "{agent} was switched off {model} outside magpie": "{agent} 在 magpie 之外被从 {model} 切走了", "{agent} was used without going through magpie — restart it after applying": "{agent} 最近的使用没有经过 magpie —— 重新应用后请重启它", "{agent} goes through magpie again": "{agent} 已重新经过 magpie", + "Update to {v}": "更新到 {v}", + "Updates with {cmd}": "通过 {cmd} 更新", + "{agent} updated to {v}": "{agent} 已更新到 {v}", + "{agent} {v} · up to date": "{agent} {v} · 已是最新", + "{agent} {v} is installed · {latest} is out": "已安装 {agent} {v} · {latest} 已发布", + "{agent} {v} · magpie can't tell how it was installed — update it the way you installed it": "{agent} {v} · magpie 无法判断它的安装方式——请用你安装它的方式更新", "{agent} hidden": "已隐藏 {agent}", "{agent} shown": "已显示 {agent}", "model": "模型", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index beb1a4f9..ffa8a63e 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -35,10 +35,17 @@ with the quota as its field, the New-Api-User header is asked for until it is typed, Check balance asks as the form has it and says the fix plainly, and the Usage page's card does too. +`cli-update.test.cjs` draws the agents' CLI versions on the Agents page +(#202) from a faked API: the version after each name, "Update to x.y.z" only +where magpie knows how the CLI was installed, the rows' height kept, an +update clicked with the view scrolled (busy, then the new version, the page +left where it was), a failed one giving its reason and the pill back, and +the words in Chinese. Nothing is installed. + With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/cli-update.test.cjs b/internal/gui/tests/cli-update.test.cjs new file mode 100644 index 00000000..eed0c46c --- /dev/null +++ b/internal/gui/tests/cli-update.test.cjs @@ -0,0 +1,152 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The agents' CLIs on the Agents page (#202): each row's version after its +// name, an "Update to x.y.z" pill where a newer one is out and magpie knows +// how the CLI was installed, the version alone where it doesn't; the rows +// keep their height; a click updates in place (busy, then the new version, +// the pill gone) with the view left where it was; a failed update says why +// and gives the pill back; the words in Chinese. No backend: the API is +// faked here, and nothing is ever installed. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const models = ["model-a", "model-b"].map((m) => ({ value: m, label: m })); +const agent = (id, used = true) => ({ + id, name: id === "codex" ? "Codex" : id, path: "/test/" + id, fields: [ + { key: "model", label: "model", value: used ? "model-a" : "", options: models }, + { key: "effort", label: "effort", value: used ? "high" : "", options: ["low", "medium", "high"].map((v) => ({ value: v, label: v })) }, + ], +}); +const state = { + agents: [...Array.from({ length: 12 }, (_, i) => agent("agent-" + i)), agent("codex"), agent("gemini"), agent("unset-1", false)], + profiles: [], settings: { lang: "en", theme: "light" }, +}; +const CLIS = { + codex: { version: "0.155.1", latest: "0.159.0", via: "self", command: "codex update", update: true }, + gemini: { version: "0.60.0", latest: "0.61.0", via: "npm", command: "npm install -g --prefix /x @google/gemini-cli@latest", update: true }, + "agent-0": { version: "1.2.3" }, // installed some way magpie can't tell + "agent-1": { version: "2.0.0", latest: "2.0.0", via: "brew", command: "brew upgrade agent-1" }, +}; + +function server(lang, posts) { + return async (route) => { + const req = route.request(), url = new URL(req.url()); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return route.fulfill({ json: { ...state, settings: { lang, theme: "light" } } }); + if (url.pathname === "/api/agents/cli" && req.method() === "GET") return route.fulfill({ json: { agents: CLIS, pending: false } }); + if (url.pathname.startsWith("/api/agents/cli/") && req.method() === "POST") { + const id = url.pathname.split("/").pop(); + posts.push(id); + await new Promise((r) => setTimeout(r, 700)); // an update takes a while + if (id === "gemini") return route.fulfill({ status: 500, json: { error: "npm install -g @google/gemini-cli@latest: EACCES: permission denied" } }); + return route.fulfill({ json: { ...CLIS[id], version: CLIS[id].latest, update: false } }); + } + if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] }); + if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } }); + if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } }); + if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} }); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const row = (id) => `.row.agent[data-id="${id}"]`; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": the agents' CLI versions and updates", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const errors = []; + t.after(async () => { + if (errors.length) console.log(errors); + await browser.close(); + }); + const open = async (lang, posts) => { + const page = await (await browser.newContext({ viewport: { width: 980, height: 520 } })).newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, posts)); + await page.goto("http://magpie.test/"); + await page.locator(`${row("codex")} .ag-ver`).waitFor(); + return page; + }; + + await t.test("versions, and a pill only where magpie can update", async () => { + const page = await open("en", []); + assert.equal(await page.locator(`${row("codex")} .ag-ver`).textContent(), "0.155.1"); + assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "Update to 0.159.0"); + assert.match(await page.locator(`${row("codex")} .ag-up`).getAttribute("title"), /codex update/); + // installed some way magpie can't tell: the version, and nothing to click + assert.equal(await page.locator(`${row("agent-0")} .ag-ver`).textContent(), "1.2.3"); + assert.equal(await page.locator(`${row("agent-0")} .ag-up`).count(), 0); + assert.match(await page.locator(`${row("agent-0")} .ag-ver`).getAttribute("title"), /can't tell how it was installed/); + // up to date + assert.equal(await page.locator(`${row("agent-1")} .ag-up`).count(), 0); + // no CLI known: nothing at all + assert.equal(await page.locator(`${row("agent-2")} .ag-ver`).count(), 0); + // on the name's line: a row with a pill is as tall as one without + const h = await page.evaluate((s) => s.map((q) => document.querySelector(q).getBoundingClientRect().height), [row("codex"), row("agent-2")]); + assert(Math.abs(h[0] - h[1]) <= 1, `rows differ in height: ${h}`); + // the name, version and pill sit on one line + const tops = await page.evaluate((r) => [".name", ".ag-ver", ".ag-up"].map((s) => { + const b = document.querySelector(r + " " + s).getBoundingClientRect(); + return b.top + b.height / 2; + }), row("codex")); + assert(Math.max(...tops) - Math.min(...tops) <= 2, `not on one line: ${tops}`); + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-cli-update.png`) }); + await page.setViewportSize({ width: 980, height: 1000 }); + await page.waitForTimeout(300); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-cli-update-all.png`) }); + } + }); + + await t.test("an update, the view left where it was", async () => { + const posts = []; + const page = await open("en", posts); + const view = page.locator("#view-agents"); + // the reader scrolls down a little, so a scroll would show + await page.mouse.move(400, 300); + for (let i = 0; i < 6; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(20); } + await page.waitForTimeout(300); + const top = await view.evaluate((v) => v.scrollTop); + assert(top > 0, "the list must be scrolled"); + const was = await page.locator(`${row("codex")} .ag-up`).evaluate((b) => b.getBoundingClientRect().top); + await page.locator(`${row("codex")} .ag-up`).click(); + await page.waitForTimeout(150); + assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "Updating…"); + assert.equal(await page.locator(`${row("codex")} .ag-up.busy`).count(), 1); + // clicked again while it runs: nothing more is asked + await page.locator(`${row("codex")} .ag-up`).click({ force: true }); + await page.waitForTimeout(900); + assert.deepEqual(posts, ["codex"]); + assert.equal(await page.locator(`${row("codex")} .ag-ver`).textContent(), "0.159.0"); + assert.equal(await page.locator(`${row("codex")} .ag-up`).count(), 0); + assert.match(await page.locator("#status").textContent(), /Codex updated to 0\.159\.0/); + assert.equal(await view.evaluate((v) => v.scrollTop), top, "the update moved the page"); + const now = await page.locator(`${row("codex")} .ag-ver`).evaluate((b) => b.getBoundingClientRect().top); + assert(Math.abs(now - was) <= 3, `the row moved ${now - was}px`); + + // one that fails says why, and can be tried again + await page.locator(`${row("gemini")} .ag-up`).click(); + await page.waitForTimeout(1000); + assert.match(await page.locator("#status").textContent(), /EACCES/); + assert.equal(await page.locator(`${row("gemini")} .ag-up`).textContent(), "Update to 0.61.0"); + assert.equal(await page.locator(`${row("gemini")} .ag-up.busy`).count(), 0); + }); + + await t.test("in Chinese", async () => { + const page = await open("zh", []); + assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "更新到 0.159.0"); + assert.match(await page.locator(`${row("codex")} .ag-up`).getAttribute("title"), /通过 codex update 更新/); + }); + + assert.deepEqual(errors, []); + }); +} diff --git a/internal/gui/tests/menu-scroll.test.cjs b/internal/gui/tests/menu-scroll.test.cjs index 3d7fe882..a060a954 100644 --- a/internal/gui/tests/menu-scroll.test.cjs +++ b/internal/gui/tests/menu-scroll.test.cjs @@ -33,6 +33,7 @@ async function serve(route) { if (url.pathname === "/api/sessions") return json({ sessions, dirs: ["/test/sessions"] }); if (url.pathname === "/api/sessions/stats") return json({ from: date, to: date, days: [{ date, usage, active: [] }], agents: { fixture: "Fixture" } }); if (url.pathname === "/api/update" || url.pathname === "/api/drift") return json({}); + if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false }); if (url.pathname === "/api/groups") return json({ groups: [] }); if (url.pathname === "/api/gateway/trace") return json({ mine: false, now: date, totals: { requests: 0, rerouted: 0, errors: 0 } }); assert(!url.pathname.startsWith("/api/"), "Unexpected API: " + url.pathname);