117 lines
3.7 KiB
Go
117 lines
3.7 KiB
Go
package provider
|
|
|
|
// Some subscriptions are used through the vendor's own CLI — Cursor's,
|
|
// Grok's, Devin's — and signed in with it too. One that isn't installed is
|
|
// installed with the vendor's own installer when its sign-in starts, as the
|
|
// vendor's page tells a person to, rather than leaving the sign-in to fail
|
|
// or, as Devin's did, to finish with nothing to show for it.
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/netproxy"
|
|
"github.com/yetone/magpie/internal/proc"
|
|
)
|
|
|
|
// agentCLI is a CLI a subscription runs through: how to find it, and the
|
|
// vendor's install one-liners for a POSIX shell and for PowerShell.
|
|
type agentCLI struct {
|
|
Name string
|
|
find func() string
|
|
sh string
|
|
ps string
|
|
}
|
|
|
|
func cliFor(agent string) (agentCLI, bool) {
|
|
switch agent {
|
|
case "devin":
|
|
return agentCLI{"Devin CLI", func() string { return DevinExecutable() },
|
|
"curl -fsSL https://cli.devin.ai/install.sh | bash",
|
|
"irm https://static.devin.ai/cli/setup.ps1 | iex"}, true
|
|
case "cursor":
|
|
return agentCLI{"Cursor CLI", func() string { return CursorExecutable() },
|
|
"curl https://cursor.com/install -fsS | bash",
|
|
"irm 'https://cursor.com/install?win32=true' | iex"}, true
|
|
case "grok":
|
|
return agentCLI{"Grok Build", func() string { return GrokExecutable() },
|
|
"curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
"irm https://x.ai/cli/install.ps1 | iex"}, true
|
|
}
|
|
return agentCLI{}, false
|
|
}
|
|
|
|
// missingCLI is the CLI an agent's sign-in needs and this machine lacks.
|
|
func missingCLI(agent string) (agentCLI, bool) {
|
|
c, ok := cliFor(agent)
|
|
if !ok || c.find() != "" {
|
|
return agentCLI{}, false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
// installTimeout bounds an installer: they download a few hundred MB at most.
|
|
var installTimeout = 10 * time.Minute
|
|
|
|
// runInstaller runs a vendor's installer; a var so tests can fake it.
|
|
var runInstaller = func(ctx context.Context, c agentCLI) ([]byte, error) {
|
|
var cmd *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
cmd = proc.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", c.ps)
|
|
} else {
|
|
cmd = proc.CommandContext(ctx, "bash", "-c", c.sh)
|
|
}
|
|
cmd.Dir, _ = os.UserHomeDir()
|
|
cmd.Env = netproxy.Env(nil)
|
|
// no stdin: an installer that would ask something takes its default
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
// installCLI installs c with its vendor's installer and makes sure magpie
|
|
// finds it afterwards.
|
|
func installCLI(ctx context.Context, c agentCLI) error {
|
|
ctx, cancel := context.WithTimeout(ctx, installTimeout)
|
|
defer cancel()
|
|
out, err := runInstaller(ctx, c)
|
|
// an installer adds its directory to the user's PATH, which a running
|
|
// program never sees
|
|
refreshPath()
|
|
manual := c.sh
|
|
if runtime.GOOS == "windows" {
|
|
manual = c.ps
|
|
}
|
|
if ctx.Err() == context.Canceled {
|
|
return ctx.Err()
|
|
}
|
|
// what counts is the CLI being there: an installer may end in a step
|
|
// that wants a terminal — Devin's runs `devin setup`, whose sign-in
|
|
// gives up without one — and fail after it installed everything
|
|
if c.find() != "" {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
msg := lastLine(out)
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return errorf("installing %s failed (%s); install it yourself with `%s`, then sign in again", c.Name, msg, manual)
|
|
}
|
|
return errorf("the %s installer finished, but magpie can't find it; install it yourself with `%s`, then sign in again", c.Name, manual)
|
|
}
|
|
|
|
// lastLine is the last line of a command's output with something on it,
|
|
// without its colours.
|
|
func lastLine(out []byte) string {
|
|
lines := strings.Split(ansi.ReplaceAllString(string(out), ""), "\n")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if l := strings.TrimSpace(lines[i]); l != "" {
|
|
return l
|
|
}
|
|
}
|
|
return ""
|
|
}
|