All checks were successful
CI / build (push) Successful in 1m45s
- Fix Docker latest version: use moby/moby instead of docker/compose - Fix uv install: add ~/.local/bin to PATH in shell rc after install - Add progress bar during tool installation in dashboard - Show spinner + per-tool progress with X/Y counter - Disable install key while installation is running 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
411 lines
13 KiB
Go
411 lines
13 KiB
Go
package installer
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"runtime"
|
||
"strings"
|
||
|
||
"github.com/muyue/muyue/internal/config"
|
||
"github.com/muyue/muyue/internal/platform"
|
||
)
|
||
|
||
type InstallResult struct {
|
||
Tool string
|
||
Success bool
|
||
Message string
|
||
}
|
||
|
||
type Installer struct {
|
||
config *config.MuyueConfig
|
||
system platform.SystemInfo
|
||
}
|
||
|
||
func New(cfg *config.MuyueConfig) *Installer {
|
||
return &Installer{
|
||
config: cfg,
|
||
system: platform.Detect(),
|
||
}
|
||
}
|
||
|
||
func (i *Installer) InstallTool(name string) InstallResult {
|
||
switch name {
|
||
case "crush":
|
||
return i.installCrush()
|
||
case "claude":
|
||
return i.installClaudeCode()
|
||
case "bmad":
|
||
return i.installBMAD()
|
||
case "starship":
|
||
return i.installStarship()
|
||
case "go":
|
||
return i.installGo()
|
||
case "node":
|
||
return i.installNode()
|
||
case "python":
|
||
return i.installPython()
|
||
case "git":
|
||
return i.installGit()
|
||
case "pnpm":
|
||
return i.installPnpm()
|
||
case "uv":
|
||
return i.installUv()
|
||
case "docker":
|
||
return i.installDocker()
|
||
case "gh":
|
||
return i.installGh()
|
||
default:
|
||
return InstallResult{Tool: name, Success: false, Message: "unknown tool"}
|
||
}
|
||
}
|
||
|
||
func (i *Installer) InstallAll(missing []string) []InstallResult {
|
||
var results []InstallResult
|
||
for _, name := range missing {
|
||
results = append(results, i.InstallTool(name))
|
||
}
|
||
return results
|
||
}
|
||
|
||
func (i *Installer) installCrush() InstallResult {
|
||
if _, err := exec.LookPath("crush"); err == nil {
|
||
return InstallResult{Tool: "crush", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
var cmd *exec.Cmd
|
||
switch i.system.OS {
|
||
case platform.Linux:
|
||
cmd = exec.Command("bash", "-c",
|
||
"curl -fsSL https://github.com/charmbracelet/crush/releases/latest/download/crush_"+runtime.GOOS+"_"+runtime.GOARCH+".tar.gz | tar xz -C /usr/local/bin")
|
||
case platform.MacOS:
|
||
cmd = exec.Command("bash", "-c", "brew install charmbracelet/tap/crush")
|
||
case platform.Windows:
|
||
cmd = exec.Command("powershell", "-Command",
|
||
"winget install charmbracelet.crush")
|
||
default:
|
||
return InstallResult{Tool: "crush", Success: false, Message: "unsupported OS"}
|
||
}
|
||
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{
|
||
Tool: "crush", Success: false,
|
||
Message: fmt.Sprintf("install failed: %s: %s", err, string(output)),
|
||
}
|
||
}
|
||
|
||
return InstallResult{Tool: "crush", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installClaudeCode() InstallResult {
|
||
if _, err := exec.LookPath("claude"); err == nil {
|
||
return InstallResult{Tool: "claude", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
cmd := exec.Command("npm", "install", "-g", "@anthropic-ai/claude-code")
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{
|
||
Tool: "claude", Success: false,
|
||
Message: fmt.Sprintf("install failed: %s: %s", err, string(output)),
|
||
}
|
||
}
|
||
|
||
return InstallResult{Tool: "claude", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installBMAD() InstallResult {
|
||
if _, err := exec.LookPath("npx"); err != nil {
|
||
return InstallResult{Tool: "bmad", Success: false, Message: "npx not found, install node first"}
|
||
}
|
||
|
||
configDir, err := config.ConfigDir()
|
||
if err != nil {
|
||
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
||
}
|
||
|
||
bmadDir := configDir + "/bmad"
|
||
os.MkdirAll(bmadDir, 0755)
|
||
|
||
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
||
"--directory", bmadDir, "--yes")
|
||
cmd.Env = append(os.Environ(), "npm_config_yes=true")
|
||
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{
|
||
Tool: "bmad", Success: false,
|
||
Message: fmt.Sprintf("install failed: %s: %s", err, string(output)),
|
||
}
|
||
}
|
||
|
||
i.config.BMAD.Installed = true
|
||
i.config.BMAD.Global = true
|
||
return InstallResult{Tool: "bmad", Success: true, Message: "installed globally in ~/.muyue/bmad"}
|
||
}
|
||
|
||
func (i *Installer) installStarship() InstallResult {
|
||
if _, err := exec.LookPath("starship"); err == nil {
|
||
return InstallResult{Tool: "starship", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
var cmd *exec.Cmd
|
||
switch i.system.OS {
|
||
case platform.Linux, platform.MacOS:
|
||
cmd = exec.Command("bash", "-c",
|
||
"curl -sS https://starship.rs/install.sh | sh -s -- -y")
|
||
case platform.Windows:
|
||
cmd = exec.Command("powershell", "-Command",
|
||
"winget install Starship.Starship")
|
||
default:
|
||
return InstallResult{Tool: "starship", Success: false, Message: "unsupported OS"}
|
||
}
|
||
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{
|
||
Tool: "starship", Success: false,
|
||
Message: fmt.Sprintf("install failed: %s: %s", err, string(output)),
|
||
}
|
||
}
|
||
|
||
return InstallResult{Tool: "starship", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installGo() InstallResult {
|
||
if _, err := exec.LookPath("go"); err == nil {
|
||
return InstallResult{Tool: "go", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
home, _ := os.UserHomeDir()
|
||
goDir := home + "/.local/go"
|
||
|
||
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
||
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
||
runtime.GOOS, runtime.GOARCH, home,
|
||
))
|
||
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{
|
||
Tool: "go", Success: false,
|
||
Message: fmt.Sprintf("install failed: %s: %s", err, string(output)),
|
||
}
|
||
}
|
||
|
||
rcFile := i.getRCFile()
|
||
appendLine(rcFile, "export PATH="+goDir+"/bin:$PATH")
|
||
|
||
return InstallResult{Tool: "go", Success: true, Message: "installed in ~/.local/go"}
|
||
}
|
||
|
||
func (i *Installer) installNode() InstallResult {
|
||
if _, err := exec.LookPath("node"); err == nil {
|
||
return InstallResult{Tool: "node", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
switch i.system.OS {
|
||
case platform.Linux:
|
||
cmd := exec.Command("bash", "-c",
|
||
"curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs")
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{Tool: "node", Success: false,
|
||
Message: fmt.Sprintf("install failed: %s", string(output))}
|
||
}
|
||
case platform.MacOS:
|
||
cmd := exec.Command("brew", "install", "node")
|
||
cmd.Run()
|
||
case platform.Windows:
|
||
cmd := exec.Command("winget", "install", "OpenJS.NodeJS.LTS")
|
||
cmd.Run()
|
||
}
|
||
|
||
return InstallResult{Tool: "node", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installPython() InstallResult {
|
||
if _, err := exec.LookPath("python3"); err == nil {
|
||
return InstallResult{Tool: "python", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
switch i.system.PackageManager {
|
||
case "apt":
|
||
exec.Command("apt", "install", "-y", "python3", "python3-pip").Run()
|
||
case "brew":
|
||
exec.Command("brew", "install", "python3").Run()
|
||
case "winget":
|
||
exec.Command("winget", "install", "Python.Python.3.12").Run()
|
||
}
|
||
|
||
return InstallResult{Tool: "python", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installGit() InstallResult {
|
||
if _, err := exec.LookPath("git"); err == nil {
|
||
return InstallResult{Tool: "git", Success: true, Message: "already installed"}
|
||
}
|
||
|
||
switch i.system.PackageManager {
|
||
case "apt":
|
||
exec.Command("apt", "install", "-y", "git").Run()
|
||
case "brew":
|
||
exec.Command("brew", "install", "git").Run()
|
||
case "winget":
|
||
exec.Command("winget", "install", "Git.Git").Run()
|
||
}
|
||
|
||
if i.config.Profile.Name != "" {
|
||
exec.Command("git", "config", "--global", "user.name", i.config.Profile.Name).Run()
|
||
}
|
||
if i.config.Profile.Email != "" {
|
||
exec.Command("git", "config", "--global", "user.email", i.config.Profile.Email).Run()
|
||
}
|
||
|
||
return InstallResult{Tool: "git", Success: true, Message: "installed and configured"}
|
||
}
|
||
|
||
func (i *Installer) SetupPrompt() error {
|
||
starshipPath, err := exec.LookPath("starship")
|
||
if err != nil {
|
||
return fmt.Errorf("starship not found")
|
||
}
|
||
|
||
rcFile := i.getRCFile()
|
||
line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell)
|
||
appendLine(rcFile, line)
|
||
|
||
configDir, _ := config.ConfigDir()
|
||
starshipConfig := `format = """
|
||
$directory\
|
||
$git_branch\
|
||
$git_status\
|
||
$git_metrics\
|
||
$nodejs\
|
||
$python\
|
||
$golang\
|
||
$rust\
|
||
$cmd_duration\
|
||
$line_break\
|
||
$character"""
|
||
|
||
[character]
|
||
success_symbol = "[❯](bold green)"
|
||
error_symbol = "[❯](bold red)"
|
||
|
||
[git_branch]
|
||
format = "[$symbol$branch]($style) "
|
||
|
||
[git_status]
|
||
format = '([$all_status$ahead_behind]($style) )'
|
||
`
|
||
configPath := configDir + "/starship.toml"
|
||
os.MkdirAll(configDir, 0755)
|
||
os.WriteFile(configPath, []byte(starshipConfig), 0644)
|
||
|
||
return nil
|
||
}
|
||
|
||
func (i *Installer) getRCFile() string {
|
||
home, _ := os.UserHomeDir()
|
||
switch i.system.Shell {
|
||
case "zsh":
|
||
return home + "/.zshrc"
|
||
case "fish":
|
||
return home + "/.config/fish/config.fish"
|
||
default:
|
||
return home + "/.bashrc"
|
||
}
|
||
}
|
||
|
||
func appendLine(file, line string) {
|
||
data, _ := os.ReadFile(file)
|
||
if strings.Contains(string(data), line) {
|
||
return
|
||
}
|
||
f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
f.WriteString("\n" + line + "\n")
|
||
}
|
||
|
||
func (i *Installer) installPnpm() InstallResult {
|
||
if _, err := exec.LookPath("pnpm"); err == nil {
|
||
return InstallResult{Tool: "pnpm", Success: true, Message: "already installed"}
|
||
}
|
||
cmd := exec.Command("npm", "install", "-g", "pnpm")
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{Tool: "pnpm", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||
}
|
||
return InstallResult{Tool: "pnpm", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installUv() InstallResult {
|
||
if _, err := exec.LookPath("uv"); err == nil {
|
||
return InstallResult{Tool: "uv", Success: true, Message: "already installed"}
|
||
}
|
||
var cmd *exec.Cmd
|
||
switch i.system.OS {
|
||
case platform.Linux, platform.MacOS:
|
||
home, _ := os.UserHomeDir()
|
||
cmd = exec.Command("bash", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||
}
|
||
rcFile := i.getRCFile()
|
||
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
|
||
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
||
case platform.Windows:
|
||
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
||
default:
|
||
return InstallResult{Tool: "uv", Success: false, Message: "unsupported OS"}
|
||
}
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||
}
|
||
return InstallResult{Tool: "uv", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installDocker() InstallResult {
|
||
if _, err := exec.LookPath("docker"); err == nil {
|
||
return InstallResult{Tool: "docker", Success: true, Message: "already installed"}
|
||
}
|
||
var cmd *exec.Cmd
|
||
switch i.system.PackageManager {
|
||
case "apt":
|
||
cmd = exec.Command("bash", "-c", "apt-get update && apt-get install -y docker.io && systemctl start docker && systemctl enable docker")
|
||
case "pacman":
|
||
cmd = exec.Command("bash", "-c", "pacman -S --noconfirm docker && systemctl start docker && systemctl enable docker")
|
||
case "dnf":
|
||
cmd = exec.Command("bash", "-c", "dnf install -y docker && systemctl start docker && systemctl enable docker")
|
||
case "brew":
|
||
cmd = exec.Command("bash", "-c", "brew install docker")
|
||
default:
|
||
return InstallResult{Tool: "docker", Success: false, Message: fmt.Sprintf("install via '%s' not supported, install manually", i.system.PackageManager)}
|
||
}
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{Tool: "docker", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||
}
|
||
return InstallResult{Tool: "docker", Success: true, Message: "installed"}
|
||
}
|
||
|
||
func (i *Installer) installGh() InstallResult {
|
||
if _, err := exec.LookPath("gh"); err == nil {
|
||
return InstallResult{Tool: "gh", Success: true, Message: "already installed"}
|
||
}
|
||
var cmd *exec.Cmd
|
||
switch i.system.PackageManager {
|
||
case "apt":
|
||
cmd = exec.Command("bash", "-c", "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo 'deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt update && apt install gh -y")
|
||
case "pacman":
|
||
cmd = exec.Command("pacman", "-S", "--noconfirm", "github-cli")
|
||
case "dnf":
|
||
cmd = exec.Command("dnf", "install", "-y", "gh")
|
||
case "brew":
|
||
cmd = exec.Command("brew", "install", "gh")
|
||
default:
|
||
return InstallResult{Tool: "gh", Success: false, Message: fmt.Sprintf("install via '%s' not supported, install manually", i.system.PackageManager)}
|
||
}
|
||
if output, err := cmd.CombinedOutput(); err != nil {
|
||
return InstallResult{Tool: "gh", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||
}
|
||
return InstallResult{Tool: "gh", Success: true, Message: "installed"}
|
||
}
|