diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index 9802723eb..843822df2 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -35,6 +35,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide/fleet" "github.com/devsy-org/devsy/pkg/ide/jetbrains" "github.com/devsy-org/devsy/pkg/ide/jupyter" + "github.com/devsy-org/devsy/pkg/ide/marimo" "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/ide/rstudio" "github.com/devsy-org/devsy/pkg/ide/vscode" @@ -617,6 +618,11 @@ func (cmd *SetupContainerCmd) installIDE( setupInfo.SubstitutionContext.ContainerWorkspaceFolder, config.GetRemoteUser(setupInfo), ide.Options). Install() + case string(config2.IDEMarimo): + return marimo.NewMarimoServer( + setupInfo.SubstitutionContext.ContainerWorkspaceFolder, + config.GetRemoteUser(setupInfo), ide.Options). + Install() case string(config2.IDERStudio): return rstudio.NewRStudioServer( setupInfo.SubstitutionContext.ContainerWorkspaceFolder, diff --git a/desktop/src/renderer/public/icons/ides/marimo.svg b/desktop/src/renderer/public/icons/ides/marimo.svg new file mode 100644 index 000000000..f28ba734b --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/marimo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/renderer/public/icons/ides/marimo_dark.svg b/desktop/src/renderer/public/icons/ides/marimo_dark.svg new file mode 100644 index 000000000..116d0b793 --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/marimo_dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index be83a2d5a..da4b5e759 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -48,6 +48,7 @@ const IDE_ICON_DARK_VARIANTS = new Set([ "code-server", "cursor", "jupyter", + "marimo", "none", "zed", ]) @@ -93,6 +94,7 @@ const IDE_GROUPS = [ label: "Other", options: [ { value: "jupyternotebook", label: "Jupyter Notebook", iconName: "jupyter" }, + { value: "marimo", label: "marimo", iconName: "marimo" }, { value: "vscode-insiders", label: "VS Code Insiders", iconName: "vscode_insiders" }, { value: "positron", label: "Positron", iconName: "positron" }, { value: "rstudio", label: "RStudio Server", iconName: "rstudio" }, diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index bed291b27..516b0c73f 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -81,6 +81,7 @@ const IDE_OPTIONS = [ { value: "clion", label: "CLion" }, { value: "dataspell", label: "DataSpell" }, { value: "jupyternotebook", label: "Jupyter Notebook" }, + { value: "marimo", label: "marimo" }, { value: "vscode-insiders", label: "VS Code Insiders" }, { value: "positron", label: "Positron" }, { value: "rstudio", label: "RStudio Server" }, diff --git a/pkg/config/ide.go b/pkg/config/ide.go index 828ce6f09..aa5009f12 100644 --- a/pkg/config/ide.go +++ b/pkg/config/ide.go @@ -28,6 +28,7 @@ const ( IDEWindsurf IDE = "windsurf" IDEAntigravity IDE = "antigravity" IDEBob IDE = "bob" + IDEMarimo IDE = "marimo" ) type IDEGroup string diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index 173909fc2..317c277f7 100644 --- a/pkg/ide/ideparse/parse.go +++ b/pkg/ide/ideparse/parse.go @@ -15,6 +15,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide/fleet" "github.com/devsy-org/devsy/pkg/ide/jetbrains" "github.com/devsy-org/devsy/pkg/ide/jupyter" + "github.com/devsy-org/devsy/pkg/ide/marimo" "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/ide/rstudio" "github.com/devsy-org/devsy/pkg/ide/vscode" @@ -172,6 +173,14 @@ var AllowedIDEs = []AllowedIDE{ IconDark: config.WebsiteAssetsURL + "/jupyter_dark.svg", Group: config.IDEGroupOther, }, + { + Name: config.IDEMarimo, + DisplayName: "marimo", + Options: marimo.Options, + Icon: config.WebsiteAssetsURL + "/marimo.svg", + IconDark: config.WebsiteAssetsURL + "/marimo_dark.svg", + Group: config.IDEGroupOther, + }, { Name: config.IDEVSCodeInsiders, DisplayName: "VS Code Insiders", diff --git a/pkg/ide/jupyter/jupyter.go b/pkg/ide/jupyter/jupyter.go index d346a593b..0c721c039 100644 --- a/pkg/ide/jupyter/jupyter.go +++ b/pkg/ide/jupyter/jupyter.go @@ -15,6 +15,8 @@ const ( BindAddressOption = "BIND_ADDRESS" ) +const DefaultServerPort = 10700 + var Options = ide.Options{ BindAddressOption: { Name: BindAddressOption, @@ -23,7 +25,11 @@ var Options = ide.Options{ }, } -const DefaultServerPort = 10700 +type JupyterNotbookServer struct { + values map[string]config.OptionValue + workspaceFolder string + userName string +} func NewJupyterNotebookServer( workspaceFolder string, @@ -37,40 +43,45 @@ func NewJupyterNotebookServer( } } -type JupyterNotbookServer struct { - values map[string]config.OptionValue - workspaceFolder string - userName string -} - func (o *JupyterNotbookServer) Install() error { - err := o.installNotebook() - if err != nil { + if err := o.installNotebook(); err != nil { return err } - return o.Start() } +func (o *JupyterNotbookServer) Start() error { + return command.StartBackgroundOnce("jupyter", func() (*exec.Cmd, error) { + log.Infof("Starting jupyter notebook in background") + runCommand := fmt.Sprintf( + "jupyter notebook --ip='*' --NotebookApp.notebook_dir='%s' --NotebookApp.token='' "+ + "--NotebookApp.password='' --no-browser --port '%s' --allow-root", + o.workspaceFolder, + strconv.Itoa(DefaultServerPort), + ) + args := []string{} + if o.userName != "" { + args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand) + } else { + args = append(args, "sh", "-l", "-c", runCommand) + } + //nolint:gosec // args are constructed from trusted inputs + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = o.workspaceFolder + return cmd, nil + }) +} + func (o *JupyterNotbookServer) installNotebook() error { if command.ExistsForUser("jupyter", o.userName) { return nil } - // check if pip3 exists - baseCommand := "" - if command.ExistsForUser("pip3", o.userName) { - baseCommand = "pip3" - } else if command.ExistsForUser("pip", o.userName) { - baseCommand = "pip" - } else { - return fmt.Errorf( - "seems like neither pip3 nor pip exists, please make sure to install python correctly", - ) + runCommand, err := ide.PythonInstallCommand(o.userName, "notebook") + if err != nil { + return err } - // install notebook command - runCommand := fmt.Sprintf("%s install notebook", baseCommand) args := []string{} if o.userName != "" { args = append(args, "su", o.userName, "-c", runCommand) @@ -78,8 +89,8 @@ func (o *JupyterNotbookServer) installNotebook() error { args = append(args, "sh", "-c", runCommand) } - // install log.Infof("installing jupyter notebook") + //nolint:gosec // args are constructed from trusted inputs out, err := exec.Command(args[0], args[1:]...).CombinedOutput() if err != nil { return fmt.Errorf( @@ -91,24 +102,3 @@ func (o *JupyterNotbookServer) installNotebook() error { log.Info("installed jupyter notebook") return nil } - -func (o *JupyterNotbookServer) Start() error { - return command.StartBackgroundOnce("jupyter", func() (*exec.Cmd, error) { - log.Infof("Starting jupyter notebook in background...") - runCommand := fmt.Sprintf( - "jupyter notebook --ip='*' --NotebookApp.notebook_dir='%s' --NotebookApp.token='' "+ - "--NotebookApp.password='' --no-browser --port '%s' --allow-root", - o.workspaceFolder, - strconv.Itoa(DefaultServerPort), - ) - args := []string{} - if o.userName != "" { - args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand) - } else { - args = append(args, "sh", "-l", "-c", runCommand) - } - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = o.workspaceFolder - return cmd, nil - }) -} diff --git a/pkg/ide/marimo/marimo.go b/pkg/ide/marimo/marimo.go new file mode 100644 index 000000000..fb6e24680 --- /dev/null +++ b/pkg/ide/marimo/marimo.go @@ -0,0 +1,110 @@ +package marimo + +import ( + "fmt" + "os/exec" + "strconv" + + "github.com/devsy-org/devsy/pkg/command" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/ide" + "github.com/devsy-org/devsy/pkg/log" +) + +const ( + BindAddressOption = "BIND_ADDRESS" + ForwardPortsOption = "FORWARD_PORTS" +) + +const DefaultServerPort = 10800 + +var Options = ide.Options{ + ForwardPortsOption: { + Name: ForwardPortsOption, + Description: "If Devsy should automatically do port-forwarding", + Default: "true", + Enum: []string{"true", "false"}, + }, + BindAddressOption: { + Name: BindAddressOption, + Description: "The address to bind the server to locally, e.g. 0.0.0.0:12345", + Default: "", + }, +} + +type MarimoServer struct { + values map[string]config.OptionValue + workspaceFolder string + userName string +} + +func NewMarimoServer( + workspaceFolder string, + userName string, + values map[string]config.OptionValue, +) *MarimoServer { + return &MarimoServer{ + values: values, + workspaceFolder: workspaceFolder, + userName: userName, + } +} + +func (o *MarimoServer) Install() error { + if err := o.installMarimo(); err != nil { + return err + } + return o.Start() +} + +func (o *MarimoServer) Start() error { + return command.StartBackgroundOnce("marimo", func() (*exec.Cmd, error) { + log.Infof("Starting marimo in background") + runCommand := fmt.Sprintf( + "marimo edit --headless --host 0.0.0.0 --port %s --no-token '%s'", + strconv.Itoa(DefaultServerPort), + o.workspaceFolder, + ) + args := []string{} + if o.userName != "" { + args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand) + } else { + args = append(args, "sh", "-l", "-c", runCommand) + } + //nolint:gosec // args are constructed from trusted inputs + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = o.workspaceFolder + return cmd, nil + }) +} + +func (o *MarimoServer) installMarimo() error { + if command.ExistsForUser("marimo", o.userName) { + return nil + } + + runCommand, err := ide.PythonInstallCommand(o.userName, "marimo") + if err != nil { + return err + } + + args := []string{} + if o.userName != "" { + args = append(args, "su", o.userName, "-c", runCommand) + } else { + args = append(args, "sh", "-c", runCommand) + } + + log.Infof("installing marimo") + //nolint:gosec // args are constructed from trusted inputs + out, err := exec.Command(args[0], args[1:]...).CombinedOutput() + if err != nil { + return fmt.Errorf( + "error installing marimo: %w", + command.WrapCommandError(out, err), + ) + } + + log.Info("installed marimo") + return nil +} diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go index 582c40dd2..c971b17ce 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -18,6 +18,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide/fleet" "github.com/devsy-org/devsy/pkg/ide/jetbrains" "github.com/devsy-org/devsy/pkg/ide/jupyter" + "github.com/devsy-org/devsy/pkg/ide/marimo" "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/ide/rstudio" "github.com/devsy-org/devsy/pkg/ide/vscode" @@ -80,6 +81,8 @@ func browserIDEOpener( return openCodeServerBrowser, true case string(config.IDEJupyterNotebook): return openJupyterBrowser, true + case string(config.IDEMarimo): + return openMarimoBrowser, true case string(config.IDERStudio): return openRStudioBrowser, true default: @@ -347,6 +350,25 @@ func openJupyterBrowser( }) } +func openMarimoBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params IDEParams, +) (string, error) { + return openBrowserIDE(ctx, params, browserIDESpec{ + BindAddrOption: marimo.Options.GetValue(ideOptions, marimo.BindAddressOption), + DefaultPort: marimo.DefaultServerPort, + ForwardPorts: marimo.Options.GetValue( + ideOptions, marimo.ForwardPortsOption, + ) == config.BoolTrue, + Label: "marimo", + LogName: "marimo", + TargetURLFn: func(port int, _ string) string { + return fmt.Sprintf("http://localhost:%d/", port) + }, + }) +} + func openRStudioBrowser( ctx context.Context, ideOptions map[string]config.OptionValue, diff --git a/pkg/ide/types.go b/pkg/ide/types.go index 3e35404ff..8d9bd478c 100644 --- a/pkg/ide/types.go +++ b/pkg/ide/types.go @@ -1,13 +1,35 @@ package ide import ( + "fmt" "io" "time" + "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" ) +// PythonInstallCommand returns the shell command that installs a Python package +// for the given user, preferring uv when present and falling back to pip3 then +// pip. Returns an error if none are available. +// +// When uv is used, the package is installed as a uv-managed tool (isolated +// env, shim binary placed in ~/.local/bin); this avoids touching system +// Python and sidesteps PEP 668 externally-managed-environment errors. The +// pip fallbacks preserve the existing system-install behavior. +func PythonInstallCommand(userName, pkg string) (string, error) { + switch { + case command.ExistsForUser("uv", userName): + return fmt.Sprintf("uv tool install %s", pkg), nil + case command.ExistsForUser("pip3", userName): + return fmt.Sprintf("pip3 install %s", pkg), nil + case command.ExistsForUser("pip", userName): + return fmt.Sprintf("pip install %s", pkg), nil + } + return "", fmt.Errorf("none of uv, pip3, or pip exist; install Python tooling first") +} + type IDE interface { Install() error } @@ -55,6 +77,8 @@ func ReusesAuthSock(ide string) bool { return true case string(config.IDEJupyterNotebook): return true + case string(config.IDEMarimo): + return true default: return false }