diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index 7dd0aea8d..16c146e6e 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -31,6 +31,7 @@ import ( "github.com/loft-sh/devpod/pkg/ide/jetbrains" "github.com/loft-sh/devpod/pkg/ide/jupyter" "github.com/loft-sh/devpod/pkg/ide/marimo" + "github.com/loft-sh/devpod/pkg/ide/neovim" "github.com/loft-sh/devpod/pkg/ide/openvscode" "github.com/loft-sh/devpod/pkg/ide/vscode" provider2 "github.com/loft-sh/devpod/pkg/provider" @@ -439,6 +440,8 @@ func (cmd *SetupContainerCmd) installIDE(setupInfo *config.Result, ide *provider return jupyter.NewJupyterNotebookServer(setupInfo.SubstitutionContext.ContainerWorkspaceFolder, config.GetRemoteUser(setupInfo), ide.Options, log).Install() case string(config2.IDEMarimo): return marimo.NewServer(setupInfo.SubstitutionContext.ContainerWorkspaceFolder, config.GetRemoteUser(setupInfo), ide.Options, log).Install() + case string(config2.IDENeoVim): + return neovim.NewServer(config.GetRemoteUser(setupInfo), ide.Options, log).Install(setupInfo.SubstitutionContext.ContainerWorkspaceFolder) } return nil diff --git a/cmd/up.go b/cmd/up.go index d825d11d5..5e39c013d 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -28,6 +28,7 @@ import ( "github.com/loft-sh/devpod/pkg/ide/jetbrains" "github.com/loft-sh/devpod/pkg/ide/jupyter" "github.com/loft-sh/devpod/pkg/ide/marimo" + "github.com/loft-sh/devpod/pkg/ide/neovim" "github.com/loft-sh/devpod/pkg/ide/openvscode" "github.com/loft-sh/devpod/pkg/ide/vscode" "github.com/loft-sh/devpod/pkg/loft" @@ -343,6 +344,18 @@ func (cmd *UpCmd) Run( return jetbrains.NewWebStormServer(config2.GetRemoteUser(result), ideConfig.Options, log).OpenGateway(result.SubstitutionContext.ContainerWorkspaceFolder, client.Workspace()) case string(config.IDEFleet): return startFleet(ctx, client, log) + case string(config.IDENeoVim): + return startNeoVim( + cmd.GPGAgentForwarding, + ctx, + devPodConfig, + client, + user, + ideConfig.Options, + cmd.GitUsername, + cmd.GitToken, + log, + ) case string(config.IDEJupyterNotebook): return startJupyterNotebookInBrowser( cmd.GPGAgentForwarding, @@ -653,6 +666,51 @@ func startMarimoInBrowser( ) } +func startNeoVim( + forwardGpg bool, + ctx context.Context, + devPodConfig *config.Config, + client client2.BaseWorkspaceClient, + user string, + ideOptions map[string]config.OptionValue, + gitUsername, gitToken string, + logger log.Logger, +) error { + if forwardGpg { + err := performGpgForwarding(client, logger) + if err != nil { + return err + } + } + // determine port + address, port, err := parseAddressAndPort( + neovim.Options.GetValue(ideOptions, neovim.BindAddressOption), + marimo.DefaultServerPort, + ) + if err != nil { + return err + } + // start tunnel + targetURL := fmt.Sprintf("http://127.0.0.1:%d", port) + logger.Info("======================================================================") + logger.Info("NeoVim has started on the remote, connect to it in a terminal using:") + logger.Infof("nvim --server %s --remote-ui", neovim.Options.GetValue(ideOptions, neovim.BindAddressOption)) + logger.Info("======================================================================") + extraPorts := []string{fmt.Sprintf("%s:%d", address, neovim.DefaultServerPort)} + return startBrowserTunnel( + ctx, + devPodConfig, + client, + user, + targetURL, + false, + extraPorts, + gitUsername, + gitToken, + logger, + ) +} + func startJupyterNotebookInBrowser( forwardGpg bool, ctx context.Context, diff --git a/pkg/config/ide.go b/pkg/config/ide.go index 9f82a427d..7c9c05d48 100644 --- a/pkg/config/ide.go +++ b/pkg/config/ide.go @@ -22,4 +22,5 @@ const ( IDECursor IDE = "cursor" IDEPositron IDE = "positron" IDEMarimo IDE = "marimo" + IDENeoVim IDE = "neovim" ) diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index 3f83bdf4f..4d80ef87e 100644 --- a/pkg/ide/ideparse/parse.go +++ b/pkg/ide/ideparse/parse.go @@ -158,6 +158,13 @@ var AllowedIDEs = []AllowedIDE{ Icon: "https://devpod.sh/assets/marimo.svg", Experimental: true, }, + { + Name: config.IDENeoVim, + DisplayName: "NeoVim", + Options: vscode.Options, + Icon: "https://devpod.sh/assets/nvim.svg", + Experimental: true, + }, } func RefreshIDEOptions(devPodConfig *config.Config, workspace *provider.Workspace, ide string, options []string) (*provider.Workspace, error) { diff --git a/pkg/ide/jetbrains/generic.go b/pkg/ide/jetbrains/generic.go index 06d7c9e54..c7bf78b40 100644 --- a/pkg/ide/jetbrains/generic.go +++ b/pkg/ide/jetbrains/generic.go @@ -9,16 +9,14 @@ import ( "path" "path/filepath" "runtime" - "time" - "github.com/loft-sh/devpod/pkg/command" "github.com/loft-sh/devpod/pkg/config" copy2 "github.com/loft-sh/devpod/pkg/copy" "github.com/loft-sh/devpod/pkg/extract" devpodhttp "github.com/loft-sh/devpod/pkg/http" "github.com/loft-sh/devpod/pkg/ide" + "github.com/loft-sh/devpod/pkg/util" "github.com/loft-sh/log" - "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/skratchdot/open-golang/open" ) @@ -96,7 +94,7 @@ func (o *GenericJetBrainsServer) getDownloadFolder() string { func (o *GenericJetBrainsServer) Install() error { o.log.Debugf("Setup %s...", o.options.DisplayName) - baseFolder, err := getBaseFolder(o.userName) + baseFolder, err := util.GetBaseFolder(o.userName) if err != nil { return err } @@ -128,21 +126,6 @@ func (o *GenericJetBrainsServer) Install() error { return nil } -func getBaseFolder(userName string) (string, error) { - var err error - homeFolder := "" - if userName != "" { - homeFolder, err = command.GetHome(userName) - } else { - homeFolder, err = homedir.Dir() - } - if err != nil { - return "", err - } - - return homeFolder, nil -} - func (o *GenericJetBrainsServer) getDirectory(baseFolder string) string { return path.Join(baseFolder, ".cache", "JetBrains", "RemoteDev", "dist", o.options.ID) } @@ -194,34 +177,10 @@ func (o *GenericJetBrainsServer) download(targetFolder string, log log.Logger) ( } defer file.Close() - _, err = io.Copy(file, &progressReader{ - reader: resp.Body, - totalSize: resp.ContentLength, - log: log, - }) + _, err = io.Copy(file, util.NewProgressReader(resp, log)) if err != nil { return "", errors.Wrap(err, "download file") } return targetPath, nil } - -type progressReader struct { - reader io.Reader - - lastMessage time.Time - bytesRead int64 - totalSize int64 - log log.Logger -} - -func (p *progressReader) Read(b []byte) (n int, err error) { - n, err = p.reader.Read(b) - p.bytesRead += int64(n) - if time.Since(p.lastMessage) > time.Second*1 { - p.log.Infof("Downloaded %.2f / %.2f MB", float64(p.bytesRead)/1024/1024, float64(p.totalSize/1024/1024)) - p.lastMessage = time.Now() - } - - return n, err -} diff --git a/pkg/ide/neovim/neovim.go b/pkg/ide/neovim/neovim.go new file mode 100644 index 000000000..727a99f22 --- /dev/null +++ b/pkg/ide/neovim/neovim.go @@ -0,0 +1,158 @@ +package neovim + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + + "github.com/loft-sh/devpod/pkg/config" + copy2 "github.com/loft-sh/devpod/pkg/copy" + "github.com/loft-sh/devpod/pkg/extract" + devpodhttp "github.com/loft-sh/devpod/pkg/http" + "github.com/loft-sh/devpod/pkg/ide" + "github.com/loft-sh/devpod/pkg/single" + "github.com/loft-sh/devpod/pkg/util" + "github.com/loft-sh/log" + "github.com/pkg/errors" +) + +const DefaultServerPort = 10720 +const ( + DownLoadURL = "DOWNLOAD_URL" + BindAddressOption = "BIND_ADDRESS" +) + +var Options = ide.Options{ + DownLoadURL: { + Name: DownLoadURL, + Description: "URL to use to download nvim", + Default: "https://github.com/neovim/neovim/releases/latest/download/nvim-linux64.tar.gz", + }, + BindAddressOption: { + Name: BindAddressOption, + Description: "The address to bind the server to locally. E.g. 0.0.0.0:12345", + Default: "127.0.0.1:10720", + }, +} + +// NewServer creates a new neovim server +func NewServer(userName string, values map[string]config.OptionValue, log log.Logger) *Server { + return &Server{ + userName: userName, + values: values, + log: log, + } +} + +// Server provides the remote the ability to download, install and run the neovim server in headless mode +type Server struct { + userName string + values map[string]config.OptionValue + log log.Logger +} + +func (o *Server) Install(workspaceFolder string) error { + o.log.Debugf("Setup neovim...") + // Define out target install location and ensure it exists + baseFolder, err := util.GetBaseFolder(o.userName) + if err != nil { + return err + } + targetLocation := path.Join(baseFolder, ".cache", "neovim") + + _, err = os.Stat(targetLocation) + if err != nil { + o.log.Debugf("Installing neovim") + // Download and extract neovim + o.log.Debugf("Download neovim archive") + archivePath, err := o.download("/var/devpod/neovim", o.log) + if err != nil { + return err + } + o.log.Infof("Extract neovim...") + err = o.extractArchive(archivePath, targetLocation) + if err != nil { + return err + } + // Ensure the remote user owns the neovim install + err = copy2.ChownR(path.Join(baseFolder, ".cache"), o.userName) + if err != nil { + return errors.Wrap(err, "chown") + } + } + + return o.start(targetLocation, workspaceFolder) +} + +func (o *Server) extractArchive(fromPath string, toPath string) error { + file, err := os.Open(fromPath) + if err != nil { + return err + } + defer file.Close() + + return extract.Extract(file, toPath, extract.StripLevels(1)) +} + +func (o *Server) download(targetFolder string, log log.Logger) (string, error) { + // Ensure the target folder exists + err := os.MkdirAll(targetFolder, os.ModePerm) + if err != nil { + return "", err + } + downloadURL := Options.GetValue(o.values, DownLoadURL) + targetPath := path.Join(filepath.ToSlash(targetFolder), "nvim-linux64.tar.gz") + + // initiate download + log.Infof("Download neovim from %s", downloadURL) + defer log.Debugf("Successfully downloaded neovim") + resp, err := devpodhttp.GetHTTPClient().Get(downloadURL) + if err != nil { + return "", errors.Wrap(err, "download binary") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", errors.Wrapf(err, "download binary returned status code %d", resp.StatusCode) + } + stat, err := os.Stat(targetPath) + if err == nil && stat.Size() == resp.ContentLength { + return targetPath, nil + } + // Download the response as a file + file, err := os.Create(targetPath) + if err != nil { + return "", err + } + defer file.Close() + + _, err = io.Copy(file, util.NewProgressReader(resp, log)) + if err != nil { + return "", errors.Wrap(err, "download file") + } + return targetPath, nil +} + +// start runs the neovim server in headless mode using a known PID file to expose at most one instance +func (o *Server) start(targetLocation, workspaceFolder string) error { + return single.Single("nvim.pid", func() (*exec.Cmd, error) { + o.log.Debug("Starting nvim in background...") + // Generate server start command using remote user + addr := Options.GetValue(o.values, BindAddressOption) + runCommand := fmt.Sprintf("%s/bin/nvim --listen %s --headless %s", targetLocation, addr, workspaceFolder) + args := []string{} + if o.userName != "" { + args = append(args, "su", o.userName, "-l", "-c", runCommand) + } else { + args = append(args, "sh", "-l", "-c", runCommand) + } + // Execute the command in the workspace folder + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = workspaceFolder + return cmd, nil + }) +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 000000000..5f5f164e6 --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,54 @@ +package util + +import ( + "io" + "net/http" + "time" + + "github.com/loft-sh/devpod/pkg/command" + "github.com/loft-sh/log" + "github.com/mitchellh/go-homedir" +) + +func GetBaseFolder(userName string) (string, error) { + var err error + homeFolder := "" + if userName != "" { + homeFolder, err = command.GetHome(userName) + } else { + homeFolder, err = homedir.Dir() + } + if err != nil { + return "", err + } + + return homeFolder, nil +} + +type ProgressReader struct { + reader io.Reader + + lastMessage time.Time + bytesRead int64 + totalSize int64 + log log.Logger +} + +func (p *ProgressReader) Read(b []byte) (n int, err error) { + n, err = p.reader.Read(b) + p.bytesRead += int64(n) + if time.Since(p.lastMessage) > time.Second*1 { + p.log.Infof("Downloaded %.2f / %.2f MB", float64(p.bytesRead)/1024/1024, float64(p.totalSize/1024/1024)) + p.lastMessage = time.Now() + } + + return n, err +} + +func NewProgressReader(resp *http.Response, log log.Logger) *ProgressReader { + return &ProgressReader{ + reader: resp.Body, + totalSize: resp.ContentLength, + log: log, + } +}