Files
MuyueWorkspace/internal/installer/installer.go
Augustin e6fdec4ff5
All checks were successful
CI / build (push) Successful in 1m45s
fix: docker version check, uv PATH, install progress bar
- 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>
2026-04-20 00:07:37 +02:00

411 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"}
}