commit 8a467d90041dd9cb0b7238ac5d82536dd350affa Author: Patryk Hegenberg Date: Mon Oct 20 10:40:40 2025 +0200 feat: initial commit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..186780b --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module lsp-manager + +go 1.25.2 + +require ( + github.com/adrg/xdg v0.5.3 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f048d2f --- /dev/null +++ b/go.sum @@ -0,0 +1,65 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.go b/install.go new file mode 100644 index 0000000..c50af7a --- /dev/null +++ b/install.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + // "github.com/adrg/xdg" +) + +// Installer verwaltet die Installation von LSP-Servern +type Installer struct { + installDir string + binDir string +} + +// NewInstaller erstellt einen XDG-konformen Installer +func NewInstaller(baseDir string) *Installer { + installDir := filepath.Join(baseDir, "servers") + binDir := filepath.Join(baseDir, "bin") + + // Verzeichnisse erstellen + os.MkdirAll(installDir, 0755) + os.MkdirAll(binDir, 0755) + + return &Installer{ + installDir: installDir, + binDir: binDir, + } +} + +// PrintPathInstructions zeigt dem Benutzer, wie der PATH gesetzt wird +func (i *Installer) PrintPathInstructions() { + fmt.Println("\n📁 LSP-Server werden installiert in:") + fmt.Printf(" Binaries: %s\n", i.binDir) + fmt.Printf(" Data: %s\n\n", i.installDir) + + // PrĂźfen ob bereits im PATH + pathEnv := os.Getenv("PATH") + pathList := filepath.SplitList(pathEnv) + inPath := false + for _, p := range pathList { + if p == i.binDir { + inPath = true + break + } + } + + if !inPath { + fmt.Println("⚠️ Das Binary-Verzeichnis ist noch nicht im PATH!") + fmt.Println("\nFĂźge folgende Zeile zu deiner Shell-Konfiguration hinzu:") + fmt.Printf("\n # FĂźr Bash (~/.bashrc):\n") + fmt.Printf(" export PATH=\"%s:$PATH\"\n\n", i.binDir) + fmt.Printf(" # FĂźr Zsh (~/.zshrc):\n") + fmt.Printf(" export PATH=\"%s:$PATH\"\n\n", i.binDir) + fmt.Printf(" # FĂźr Fish (~/.config/fish/config.fish):\n") + fmt.Printf(" set -x PATH %s $PATH\n\n", i.binDir) + } else { + fmt.Println("✅ Binary-Verzeichnis ist bereits im PATH!") + } +} + +// Install installiert einen LSP-Server basierend auf dem Typ +func (i *Installer) Install(server LSPServer) error { + switch server.InstallType { + case "go", "golang": + return i.installGo(server) + case "npm": + return i.installNpm(server) + case "github": + return i.installGithub(server) + case "cargo": + return i.installCargo(server) + default: + return fmt.Errorf("unsupported install type: %s (fĂźr %s)", server.InstallType, server.Name) + } +} + +// installGo installiert einen LSP-Server via go install +func (i *Installer) installGo(server LSPServer) error { + // Mason Source format: "golang.org/x/tools/gopls" oder ähnlich + source := server.Source + if !strings.Contains(source, "@") { + source = source + "@" + server.Version + } + + cmd := exec.Command("go", "install", source) + cmd.Env = append(os.Environ(), + "GOBIN="+i.binDir, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("go install failed: %v\nOutput: %s", err, output) + } + + return nil +} + +// installNpm installiert einen LSP-Server via npm +func (i *Installer) installNpm(server LSPServer) error { + serverDir := filepath.Join(i.installDir, server.Name) + os.MkdirAll(serverDir, 0755) + + // Mason Source format: "package-name" (einfacher npm Package-Name) + cmd := exec.Command("npm", "install", "-g", "--prefix", serverDir, server.Source) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("npm install failed: %v\nOutput: %s", err, output) + } + + // Symlink zum bin-Verzeichnis erstellen + // npm installiert in serverDir/lib/node_modules/.bin/ + binPath := filepath.Join(serverDir, "lib", "node_modules", ".bin", server.Executable) + linkPath := filepath.Join(i.binDir, server.Executable) + + os.Remove(linkPath) // Alten Link entfernen + return os.Symlink(binPath, linkPath) +} + +// installGithub lädt ein Binary von GitHub Releases herunter +func (i *Installer) installGithub(server LSPServer) error { + // Mason Source format: "owner/repo" + // Wir mĂźssen die Download-URL konstruieren (vereinfacht) + downloadURL := fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", + server.Source, server.Executable) + + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + targetPath := filepath.Join(i.binDir, server.Executable) + file, err := os.Create(targetPath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return err + } + + // Executable-Rechte setzen + return os.Chmod(targetPath, 0755) +} + +// installCargo installiert einen LSP-Server via cargo +func (i *Installer) installCargo(server LSPServer) error { + serverDir := filepath.Join(i.installDir, server.Name) + + // Mason Source format: "crate-name" + cmd := exec.Command("cargo", "install", + server.Source, + "--root", serverDir) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cargo install failed: %v\nOutput: %s", err, output) + } + + // Symlink erstellen + binPath := filepath.Join(serverDir, "bin", server.Executable) + linkPath := filepath.Join(i.binDir, server.Executable) + + os.Remove(linkPath) + return os.Symlink(binPath, linkPath) +} + +// IsInstalled prĂźft, ob ein Server installiert ist +func (i *Installer) IsInstalled(server LSPServer) bool { + binPath := filepath.Join(i.binDir, server.Executable) + _, err := os.Stat(binPath) + return err == nil +} + +// Uninstall entfernt einen installierten LSP-Server +func (i *Installer) Uninstall(server LSPServer) error { + binPath := filepath.Join(i.binDir, server.Executable) + serverDir := filepath.Join(i.installDir, server.Name) + + os.Remove(binPath) + os.RemoveAll(serverDir) + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1b81294 --- /dev/null +++ b/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "fmt" + "strings" + "path/filepath" + "os" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + MarginLeft(2) + + selectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")) + + installedBadge = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#04B575")). + Render(" [INSTALLED]") +) + +// serverItem implementiert list.Item fĂźr Bubbletea +type serverItem struct { + server LSPServer + installed bool +} + +func (i serverItem) FilterValue() string { + return i.server.Name +} + +func (i serverItem) Title() string { + title := i.server.Name + if i.installed { + title += installedBadge + } + return title +} + +func (i serverItem) Description() string { + return fmt.Sprintf("%s | Languages: %s", + i.server.Description, + strings.Join(i.server.Languages, ", ")) +} + +// Model fĂźr die Bubbletea-Anwendung +type model struct { + list list.Model + registry *Registry + installer *Installer + status string + quitting bool +} + +// installMsg wird nach erfolgreicher Installation gesendet +type installMsg struct { + server LSPServer + err error +} + +// updateMsg wird nach Registry-Update gesendet +type updateMsg struct { + registry *Registry + err error +} + +// Init initialisiert das Model +func (m model) Init() tea.Cmd { + return nil +} + +// Update behandelt Nachrichten und aktualisiert das Model +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + + case "u": + // Registry aktualisieren (force update) + m.status = "Updating registry from Mason..." + return m, func() tea.Msg { + registry, err := LoadRegistryFromMason() + return updateMsg{registry: registry, err: err} + } + + case "enter": + // Server installieren/deinstallieren + selectedItem := m.list.SelectedItem().(serverItem) + + if selectedItem.installed { + // Deinstallieren + m.status = fmt.Sprintf("Uninstalling %s...", selectedItem.server.Name) + return m, func() tea.Msg { + err := m.installer.Uninstall(selectedItem.server) + return installMsg{server: selectedItem.server, err: err} + } + } else { + // Installieren + m.status = fmt.Sprintf("Installing %s...", selectedItem.server.Name) + return m, func() tea.Msg { + err := m.installer.Install(selectedItem.server) + return installMsg{server: selectedItem.server, err: err} + } + } + + case "i": + // Info anzeigen + selectedItem := m.list.SelectedItem().(serverItem) + m.status = fmt.Sprintf("Server: %s | Homepage: %s | License: %s | Type: %s", + selectedItem.server.Name, + selectedItem.server.Homepage, + selectedItem.server.License, + selectedItem.server.InstallType) + return m, nil + } + + case installMsg: + // Installation abgeschlossen + if msg.err != nil { + m.status = fmt.Sprintf("❌ Error: %v", msg.err) + } else { + m.status = fmt.Sprintf("✅ Successfully installed %s", msg.server.Name) + } + + // Liste aktualisieren + items := m.buildListItems() + m.list.SetItems(items) + + return m, nil + + case updateMsg: + // Registry-Update abgeschlossen + if msg.err != nil { + m.status = fmt.Sprintf("❌ Registry update failed: %v", msg.err) + } else { + m.registry = msg.registry + m.status = fmt.Sprintf("✅ Registry updated with %d LSP servers", len(msg.registry.Servers)) + // Liste aktualisieren + items := m.buildListItems() + m.list.SetItems(items) + } + return m, nil + + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + m.list.SetHeight(msg.Height - 5) + return m, nil + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +// View rendert die UI +func (m model) View() string { + if m.quitting { + return "Goodbye! 👋\n" + } + + header := titleStyle.Render("🚀 LSP Server Manager (Mason Registry)") + + helpText := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#626262")). + Render("\nEnter: Install/Uninstall | i: Info | u: Update Registry | q: Quit | /: Filter") + + statusBar := "" + if m.status != "" { + statusBar = "\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Render(m.status) + } + + return fmt.Sprintf("%s\n\n%s%s%s\n", + header, + m.list.View(), + statusBar, + helpText) +} + +// buildListItems erstellt die Liste der Server-Items +func (m *model) buildListItems() []list.Item { + items := make([]list.Item, len(m.registry.Servers)) + + for i, server := range m.registry.Servers { + items[i] = serverItem{ + server: server, + installed: m.installer.IsInstalled(server), + } + } + + return items +} + +// Hauptfunktion +func main() { + // Registry laden - verwende "mason" um von GitHub zu laden + // oder einen Dateipfad fĂźr lokale registry.json + registry, err := LoadRegistry("mason") + if err != nil { + fmt.Printf("Error loading registry: %v\n", err) + return + } + + fmt.Printf("Loaded %d LSP servers from registry\n\n", len(registry.Servers)) + + // Installer erstellen + homeDir, _ := os.UserHomeDir() + installDir := filepath.Join(homeDir, ".lsp-manager") + installer := NewInstaller(installDir) + installer.PrintPathInstructions() + + // Liste erstellen + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = selectedStyle + delegate.Styles.SelectedDesc = selectedStyle + + initialModel := model{ + registry: registry, + installer: installer, + } + + items := initialModel.buildListItems() + initialModel.list = list.New(items, delegate, 0, 0) + initialModel.list.Title = "Available LSP Servers" + initialModel.list.SetFilteringEnabled(true) + + // Bubbletea-Programm starten + p := tea.NewProgram(initialModel, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..a4729b0 --- /dev/null +++ b/registry.go @@ -0,0 +1,289 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/adrg/xdg" +) + +const ( + MASON_REGISTRY_RELEASE = "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" +) + +// LSPServer repräsentiert einen Language Server +type LSPServer struct { + Name string `json:"name"` + Description string `json:"description"` + Homepage string `json:"homepage"` + Languages []string `json:"languages"` + Categories []string `json:"categories"` + Executable string `json:"executable"` + InstallType string `json:"install_type"` // "github", "npm", "cargo", "go" + Source string `json:"source"` // URL oder Package-Name + Version string `json:"version"` + License string `json:"license"` +} + +// Registry verwaltet alle verfĂźgbaren LSP-Server +type Registry struct { + Servers []LSPServer `json:"servers"` + CachePath string + LastUpdated time.Time +} + +// MasonPackage entspricht der Mason-Registry Struktur +type MasonPackage struct { + Name string `json:"name"` + Description string `json:"description"` + Homepage string `json:"homepage"` + Licenses []string `json:"licenses"` + Languages []string `json:"languages"` + Categories []string `json:"categories"` + Source struct { + ID string `json:"id"` + } `json:"source"` + Bin map[string]string `json:"bin,omitempty"` +} + +// LoadRegistry lädt die Registry aus einer JSON-Datei oder von Mason +func LoadRegistry(path string) (*Registry, error) { + // PrĂźfe ob path "mason" ist -> dann von GitHub laden + if path == "mason" { + return LoadRegistryFromMason() + } + + // Ansonsten aus lokaler Datei laden + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + var registry Registry + if err := json.Unmarshal(data, ®istry); err != nil { + return nil, err + } + + return ®istry, nil +} + +// LoadRegistryFromMason lädt die Registry von Mason mit Cache +func LoadRegistryFromMason() (*Registry, error) { + cacheDir := filepath.Join(xdg.CacheHome, "lsp-manager") + os.MkdirAll(cacheDir, 0755) + cachePath := filepath.Join(cacheDir, "mason-registry.json") + + registry := &Registry{ + CachePath: cachePath, + } + + // PrĂźfe ob Cache existiert und aktuell ist (< 24h alt) + if info, err := os.Stat(cachePath); err == nil { + if time.Since(info.ModTime()) < 24*time.Hour { + fmt.Println("📦 Loading registry from cache...") + if err := registry.loadFromCache(); err == nil { + return registry, nil + } + fmt.Printf("Cache load failed, downloading fresh copy...\n") + } + } + + // Cache ist veraltet oder existiert nicht, lade von GitHub + if err := registry.downloadFromMason(); err != nil { + return nil, err + } + + return registry, nil +} + +// loadFromCache lädt die Registry aus dem lokalen Cache +func (r *Registry) loadFromCache() error { + data, err := os.ReadFile(r.CachePath) + if err != nil { + return fmt.Errorf("failed to read cache: %w", err) + } + + if err := json.Unmarshal(data, &r.Servers); err != nil { + return fmt.Errorf("failed to parse cache: %w", err) + } + + return nil +} + +// downloadFromMason lädt die Mason-Registry von GitHub +func (r *Registry) downloadFromMason() error { + fmt.Println("📦 Downloading Mason registry from GitHub...") + + resp, err := http.Get(MASON_REGISTRY_RELEASE) + if err != nil { + return fmt.Errorf("failed to download registry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Lese ZIP-Daten in Speicher + zipData, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read registry data: %w", err) + } + + // Entpacke ZIP + zipReader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + + // Finde registry.json in ZIP + var registryJSON []byte + for _, file := range zipReader.File { + if file.Name == "registry.json" { + f, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open registry.json: %w", err) + } + registryJSON, err = io.ReadAll(f) + f.Close() + if err != nil { + return fmt.Errorf("failed to read registry.json: %w", err) + } + break + } + } + + if registryJSON == nil { + return fmt.Errorf("registry.json not found in archive") + } + + // Parse Mason-Format: registry.json ist ein Array + var masonPackages []MasonPackage + if err := json.Unmarshal(registryJSON, &masonPackages); err != nil { + return fmt.Errorf("failed to parse registry: %w", err) + } + + // Konvertiere zu unserem Format + r.Servers = convertMasonToLSPServers(masonPackages) + + fmt.Printf("✅ Registry updated with %d packages (%d LSP servers)\n", + len(masonPackages), len(r.Servers)) + + // Speichere konvertierte Servers im Cache + if cacheData, err := json.MarshalIndent(r.Servers, "", " "); err == nil { + if err := os.WriteFile(r.CachePath, cacheData, 0644); err != nil { + fmt.Printf("⚠️ Warning: failed to cache registry: %v\n", err) + } + } + + r.LastUpdated = time.Now() + return nil +} + +func convertMasonToLSPServers(masonPackages []MasonPackage) []LSPServer { + servers := make([]LSPServer, 0, len(masonPackages)) + + for _, pkg := range masonPackages { + // Filtere nur LSP-Server + isLSP := false + for _, cat := range pkg.Categories { + if cat == "LSP" { + isLSP = true + break + } + } + + if !isLSP { + continue + } + + // Bestimme Executable-Namen + executable := pkg.Name + if len(pkg.Bin) > 0 { + for _, binName := range pkg.Bin { + executable = binName + break + } + } + + // Konvertiere Source-ID zu unserem Format + installType, source := parseMasonSourceID(pkg.Source.ID) + + servers = append(servers, LSPServer{ + Name: pkg.Name, + Description: pkg.Description, + Homepage: pkg.Homepage, + Languages: pkg.Languages, + Categories: pkg.Categories, + Executable: executable, + InstallType: installType, + Source: source, + Version: "latest", + License: getLicense(pkg.Licenses), + }) + } + + return servers +} + +// parseMasonSourceID extrahiert InstallType und Source aus Mason Source-ID +// Format: "pkg:github/owner/repo", "pkg:npm/package-name", etc. +func parseMasonSourceID(sourceID string) (installType, source string) { + if !strings.HasPrefix(sourceID, "pkg:") { + return "unknown", sourceID + } + + // Entferne "pkg:" Prefix + parts := strings.SplitN(sourceID[4:], "/", 2) + if len(parts) < 2 { + return "unknown", sourceID + } + + installType = parts[0] + source = parts[1] + + return installType, source +} + +// getLicense gibt die erste Lizenz zurĂźck oder "Unknown" +func getLicense(licenses []string) string { + if len(licenses) > 0 { + return licenses[0] + } + return "Unknown" +} + +// LoadRegistryFromURL lädt von einer Remote-URL (Fallback) +func LoadRegistryFromURL(url string) (*Registry, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var registry Registry + if err := json.Unmarshal(data, ®istry); err != nil { + return nil, err + } + + return ®istry, nil +}