From 6e41da96eec8905b8e5894a12e4f3f145db2683b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 27 May 2026 15:47:56 -0500 Subject: [PATCH] feat(ide): add code-server (coder.com) as a browser-based IDE option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing openvscode-server plumbing: new pkg/ide/codeserver package handles tarball install, settings, extension install and a background Start(); wired into the container setup switch, the browser-tunnel opener, the SSH auth-sock reuse helper, and the tunnel port-forward list. Default port 10801 to sit next to openvscode's 10800. Also removes the AllowedIDE.Experimental field entirely — leftover from the devpod fork with no remaining purpose. JSON tag had omitempty so API consumers see no change. --- cmd/agent/container/codeserver_async.go | 59 +++ cmd/agent/container/container.go | 1 + cmd/agent/container/setup.go | 60 ++++ .../public/icons/ides/code-server.svg | 3 + .../src/renderer/public/icons/ides/none.svg | 11 + .../renderer/public/icons/ides/none_dark.svg | 11 + .../public/icons/ides/vscodebrowser.svg | 32 ++ .../workspace/WorkspaceWizard.svelte | 72 ++-- pkg/config/ide.go | 1 + pkg/ide/codeserver/codeserver.go | 336 ++++++++++++++++++ pkg/ide/ideparse/parse.go | 133 ++++--- pkg/ide/opener/browser_tunnel_state.go | 4 + pkg/ide/opener/opener.go | 167 +++++---- pkg/ide/types.go | 2 + pkg/tunnel/services.go | 2 + 15 files changed, 711 insertions(+), 183 deletions(-) create mode 100644 cmd/agent/container/codeserver_async.go create mode 100644 desktop/src/renderer/public/icons/ides/code-server.svg create mode 100644 desktop/src/renderer/public/icons/ides/none.svg create mode 100644 desktop/src/renderer/public/icons/ides/none_dark.svg create mode 100644 desktop/src/renderer/public/icons/ides/vscodebrowser.svg create mode 100644 pkg/ide/codeserver/codeserver.go diff --git a/cmd/agent/container/codeserver_async.go b/cmd/agent/container/codeserver_async.go new file mode 100644 index 000000000..4d21ac8fe --- /dev/null +++ b/cmd/agent/container/codeserver_async.go @@ -0,0 +1,59 @@ +package container + +import ( + "encoding/json" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/compress" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/ide/codeserver" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +// CodeServerAsyncCmd holds the cmd flags. +type CodeServerAsyncCmd struct { + *flags.GlobalFlags + + SetupInfo string +} + +// NewCodeServerAsyncCmd creates a new command. +func NewCodeServerAsyncCmd() *cobra.Command { + cmd := &CodeServerAsyncCmd{} + codeServerAsyncCmd := &cobra.Command{ + Use: "code-server-async", + Short: "Starts code-server", + Args: cobra.NoArgs, + RunE: cmd.Run, + } + codeServerAsyncCmd.Flags(). + StringVar(&cmd.SetupInfo, "setup-info", "", "The container setup info") + _ = codeServerAsyncCmd.MarkFlagRequired("setup-info") + return codeServerAsyncCmd +} + +// Run runs the command logic. +func (cmd *CodeServerAsyncCmd) Run(_ *cobra.Command, _ []string) error { + log.Debugf("Start setting up container...") + decompressed, err := compress.Decompress(cmd.SetupInfo) + if err != nil { + return err + } + + setupInfo := &config.Result{} + if err := json.Unmarshal([]byte(decompressed), setupInfo); err != nil { + return err + } + + return setupCodeServerExtensions(setupInfo) +} + +func setupCodeServerExtensions(setupInfo *config.Result) error { + vsCodeConfiguration := config.GetVSCodeConfiguration(setupInfo.MergedConfig) + user := config.GetRemoteUser(setupInfo) + return codeserver.NewCodeServer(codeserver.ServerOptions{ + Extensions: vsCodeConfiguration.Extensions, + UserName: user, + }).InstallExtensions() +} diff --git a/cmd/agent/container/container.go b/cmd/agent/container/container.go index 354e26225..7572f172a 100644 --- a/cmd/agent/container/container.go +++ b/cmd/agent/container/container.go @@ -18,6 +18,7 @@ func NewContainerCmd(flags *flags.GlobalFlags) *cobra.Command { containerCmd.AddCommand(NewDaemonCmd()) containerCmd.AddCommand(NewVSCodeAsyncCmd()) containerCmd.AddCommand(NewOpenVSCodeAsyncCmd()) + containerCmd.AddCommand(NewCodeServerAsyncCmd()) containerCmd.AddCommand(NewCredentialsServerCmd(flags)) containerCmd.AddCommand(NewSetupLoftPlatformAccessCmd(flags)) containerCmd.AddCommand(NewSSHServerCmd(flags)) diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index 3ddf95e3a..9802723eb 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -31,6 +31,7 @@ import ( "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/extract" "github.com/devsy-org/devsy/pkg/git" + "github.com/devsy-org/devsy/pkg/ide/codeserver" "github.com/devsy-org/devsy/pkg/ide/fleet" "github.com/devsy-org/devsy/pkg/ide/jetbrains" "github.com/devsy-org/devsy/pkg/ide/jupyter" @@ -576,6 +577,8 @@ func (cmd *SetupContainerCmd) installIDE( return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorBob) case string(config2.IDEOpenVSCode): return cmd.setupOpenVSCode(setupInfo, ide.Options) + case string(config2.IDECodeServer): + return cmd.setupCodeServer(setupInfo, ide.Options) case string(config2.IDEGoland): return jetbrains.NewGolandServer(config.GetRemoteUser(setupInfo), ide.Options). Install(setupInfo) @@ -748,6 +751,63 @@ func (cmd *SetupContainerCmd) setupOpenVSCode( return openVSCode.Start() } +func (cmd *SetupContainerCmd) setupCodeServer( + setupInfo *config.Result, + ideOptions map[string]config2.OptionValue, +) error { + log.Debugf("setup code-server") + vsCodeConfiguration := config.GetVSCodeConfiguration(setupInfo.MergedConfig) + settings := "" + if len(vsCodeConfiguration.Settings) > 0 { + out, err := json.Marshal(vsCodeConfiguration.Settings) + if err != nil { + return err + } + settings = string(out) + } + + user := config.GetRemoteUser(setupInfo) + cs := codeserver.NewCodeServer(codeserver.ServerOptions{ + Extensions: vsCodeConfiguration.Extensions, + Settings: settings, + UserName: user, + Host: "0.0.0.0", + Port: strconv.Itoa(codeserver.DefaultCodeServerPort), + Values: ideOptions, + }) + + if err := cs.Install(); err != nil { + return err + } + + if len(vsCodeConfiguration.Extensions) > 0 { + err := command.StartBackgroundOnce("code-server-async", func() (*exec.Cmd, error) { + log.Infof( + "installing extensions in the background: %s", + strings.Join(vsCodeConfiguration.Extensions, ","), + ) + binaryPath, err := os.Executable() + if err != nil { + return nil, err + } + //nolint:gosec // binaryPath is from os.Executable(), not user input + return exec.Command( + binaryPath, + "agent", + "container", + "code-server-async", + "--setup-info", + cmd.SetupInfo, + ), nil + }) + if err != nil { + return fmt.Errorf("install extensions: %w", err) + } + } + + return cs.Start() +} + func configureSystemGitCredentials( ctx context.Context, client tunnel.TunnelClient, diff --git a/desktop/src/renderer/public/icons/ides/code-server.svg b/desktop/src/renderer/public/icons/ides/code-server.svg new file mode 100644 index 000000000..01a01541e --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/code-server.svg @@ -0,0 +1,3 @@ + + + diff --git a/desktop/src/renderer/public/icons/ides/none.svg b/desktop/src/renderer/public/icons/ides/none.svg new file mode 100644 index 000000000..6e3abf5d7 --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/none.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/desktop/src/renderer/public/icons/ides/none_dark.svg b/desktop/src/renderer/public/icons/ides/none_dark.svg new file mode 100644 index 000000000..5f81eff52 --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/none_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/desktop/src/renderer/public/icons/ides/vscodebrowser.svg b/desktop/src/renderer/public/icons/ides/vscodebrowser.svg new file mode 100644 index 000000000..b405f35f7 --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/vscodebrowser.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index a1e338e18..cf1df4232 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -40,44 +40,47 @@ const STEPS: { id: Step; label: string }[] = [ { id: "launch", label: "Launch" }, ] +const ideIcon = (name: string) => `/icons/ides/${name}.svg` + const IDE_GROUPS = [ { label: "Primary", options: [ - { value: "none", label: "None" }, - { value: "vscode", label: "VS Code" }, - { value: "openvscode", label: "VS Code Browser" }, - { value: "cursor", label: "Cursor" }, - { value: "zed", label: "Zed" }, - { value: "codium", label: "VSCodium" }, - { value: "windsurf", label: "Windsurf Editor" }, - { value: "antigravity", label: "Google Antigravity" }, - { value: "bob", label: "IBM Bob" }, + { value: "none", label: "None", icon: ideIcon("none") }, + { value: "vscode", label: "VS Code", icon: ideIcon("vscode") }, + { value: "openvscode", label: "VS Code Browser", icon: ideIcon("vscodebrowser") }, + { value: "code-server", label: "code-server", icon: ideIcon("code-server") }, + { value: "cursor", label: "Cursor", icon: ideIcon("cursor") }, + { value: "zed", label: "Zed", icon: ideIcon("zed") }, + { value: "codium", label: "VSCodium", icon: ideIcon("codium") }, + { value: "windsurf", label: "Windsurf Editor", icon: ideIcon("windsurf") }, + { value: "antigravity", label: "Google Antigravity", icon: ideIcon("antigravity") }, + { value: "bob", label: "IBM Bob", icon: ideIcon("bob") }, ], }, { label: "JetBrains", options: [ - { value: "intellij", label: "IntelliJ IDEA" }, - { value: "pycharm", label: "PyCharm" }, - { value: "phpstorm", label: "PhpStorm" }, - { value: "rider", label: "Rider" }, - { value: "fleet", label: "Fleet" }, - { value: "goland", label: "GoLand" }, - { value: "webstorm", label: "WebStorm" }, - { value: "rustrover", label: "RustRover" }, - { value: "rubymine", label: "RubyMine" }, - { value: "clion", label: "CLion" }, - { value: "dataspell", label: "DataSpell" }, + { value: "intellij", label: "IntelliJ IDEA", icon: ideIcon("intellij") }, + { value: "pycharm", label: "PyCharm", icon: ideIcon("pycharm") }, + { value: "phpstorm", label: "PhpStorm", icon: ideIcon("phpstorm") }, + { value: "rider", label: "Rider", icon: ideIcon("rider") }, + { value: "fleet", label: "Fleet", icon: ideIcon("fleet") }, + { value: "goland", label: "GoLand", icon: ideIcon("goland") }, + { value: "webstorm", label: "WebStorm", icon: ideIcon("webstorm") }, + { value: "rustrover", label: "RustRover", icon: ideIcon("rustrover") }, + { value: "rubymine", label: "RubyMine", icon: ideIcon("rubymine") }, + { value: "clion", label: "CLion", icon: ideIcon("clion") }, + { value: "dataspell", label: "DataSpell", icon: ideIcon("dataspell") }, ], }, { label: "Other", options: [ - { value: "jupyternotebook", label: "Jupyter Notebook" }, - { value: "vscode-insiders", label: "VS Code Insiders" }, - { value: "positron", label: "Positron" }, - { value: "rstudio", label: "RStudio Server" }, + { value: "jupyternotebook", label: "Jupyter Notebook", icon: ideIcon("jupyter") }, + { value: "vscode-insiders", label: "VS Code Insiders", icon: ideIcon("vscode_insiders") }, + { value: "positron", label: "Positron", icon: ideIcon("positron") }, + { value: "rstudio", label: "RStudio Server", icon: ideIcon("rstudio") }, ], }, ] @@ -136,9 +139,9 @@ let initializedProviders = $derived( $providers.filter((p) => p.state?.initialized === true), ) -const ideLabel = $derived( - ALL_IDES.find((i) => i.value === selectedIde)?.label ?? "Select an IDE...", -) +const selectedIdeEntry = $derived(ALL_IDES.find((i) => i.value === selectedIde)) +const ideLabel = $derived(selectedIdeEntry?.label ?? "Select an IDE...") +const ideIconSrc = $derived(selectedIdeEntry?.icon) let filteredIdes = $derived( ideSearch @@ -540,7 +543,12 @@ function selectTemplate(t: { name: string; source: string }) { {#snippet child({ props })} {/snippet} @@ -558,6 +566,7 @@ function selectTemplate(t: { name: string; source: string }) { onSelect={() => { selectedIde = ide.value; ideComboOpen = false; ideSearch = "" }} > + {ide.label} {/each} @@ -628,7 +637,12 @@ function selectTemplate(t: { name: string; source: string }) { {/if}
IDE - {ideLabel} + + {#if ideIconSrc} + + {/if} + {ideLabel} +
Workspace ID diff --git a/pkg/config/ide.go b/pkg/config/ide.go index 441142d99..828ce6f09 100644 --- a/pkg/config/ide.go +++ b/pkg/config/ide.go @@ -7,6 +7,7 @@ const ( IDEVSCode IDE = "vscode" IDEVSCodeInsiders IDE = "vscode-insiders" IDEOpenVSCode IDE = "openvscode" + IDECodeServer IDE = "code-server" IDEIntellij IDE = "intellij" IDEGoland IDE = "goland" IDERustRover IDE = "rustrover" diff --git a/pkg/ide/codeserver/codeserver.go b/pkg/ide/codeserver/codeserver.go new file mode 100644 index 000000000..7479cb07c --- /dev/null +++ b/pkg/ide/codeserver/codeserver.go @@ -0,0 +1,336 @@ +package codeserver + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + + "github.com/devsy-org/devsy/pkg/command" + "github.com/devsy-org/devsy/pkg/config" + copy2 "github.com/devsy-org/devsy/pkg/copy" + "github.com/devsy-org/devsy/pkg/extract" + devsyhttp "github.com/devsy-org/devsy/pkg/http" + "github.com/devsy-org/devsy/pkg/ide" + "github.com/devsy-org/devsy/pkg/ide/vscode" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/util" +) + +// Release URL format: tag has the `v` prefix; the asset filename does not. +const ( + downloadAmd64Template = "https://github.com/coder/code-server/releases/download/" + + "v%s/code-server-%s-linux-amd64.tar.gz" + downloadArm64Template = "https://github.com/coder/code-server/releases/download/" + + "v%s/code-server-%s-linux-arm64.tar.gz" +) + +const ( + ForwardPortsOption = "FORWARD_PORTS" + BindAddressOption = "BIND_ADDRESS" + VersionOption = "VERSION" + DownloadAmd64Option = "DOWNLOAD_AMD64" + DownloadArm64Option = "DOWNLOAD_ARM64" +) + +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 code-server to locally, e.g. 0.0.0.0:12345", + Default: "", + }, + VersionOption: { + Name: VersionOption, + Description: "The version for the code-server binary (without the leading v)", + Default: "4.121.0", + }, + DownloadArm64Option: { + Name: DownloadArm64Option, + Description: "The download url for the arm64 code-server binary", + }, + DownloadAmd64Option: { + Name: DownloadAmd64Option, + Description: "The download url for the amd64 code-server binary", + }, +} + +// DefaultCodeServerPort sits next to openvscode's 10800 so a host running both +// IDEs only collides under the FindAvailablePort fallback path, not by default. +const DefaultCodeServerPort = 10801 + +// ServerOptions configures a CodeServer instance. +type ServerOptions struct { + Extensions []string + Settings string + UserName string + Host string + Port string + Values map[string]config.OptionValue +} + +// CodeServer installs and runs code-server (coder.com) inside the workspace. +type CodeServer struct { + values map[string]config.OptionValue + extensions []string + settings string + userName string + host string + port string +} + +func NewCodeServer(opts ServerOptions) *CodeServer { + host := opts.Host + if host == "" { + host = "0.0.0.0" + } + port := opts.Port + if port == "" { + port = strconv.Itoa(DefaultCodeServerPort) + } + return &CodeServer{ + values: opts.Values, + extensions: opts.Extensions, + settings: opts.Settings, + userName: opts.UserName, + host: host, + port: port, + } +} + +// Install downloads the code-server release tarball, extracts it under +// /.code-server, and writes settings.json. Idempotent: returns nil +// without re-downloading when the binary is already present. +func (c *CodeServer) Install() error { + location, err := prepareCodeServerLocation(c.userName) + if err != nil { + return err + } + + if _, err := os.Stat(filepath.Join(location, "bin", "code-server")); err == nil { + return nil + } + + vscode.InstallAPKRequirements() + + if err := downloadAndExtract(c.getReleaseURL(), location); err != nil { + return err + } + + if c.userName != "" { + if err := copy2.ChownR(location, c.userName); err != nil { + return fmt.Errorf("chown: %w", err) + } + } + + if err := c.installSettings(); err != nil { + return fmt.Errorf("install settings: %w", err) + } + + return nil +} + +// InstallExtensions installs each requested extension via the code-server CLI. +// Partial failures are logged and tolerated; an aggregated error is returned +// only when every requested extension fails to install. +func (c *CodeServer) InstallExtensions() error { + if err := c.installExtensions(); err != nil { + return fmt.Errorf("install extensions: %w", err) + } + return nil +} + +// Start launches code-server as a background process bound to host:port. +// Idempotent via command.StartBackgroundOnce. +func (c *CodeServer) Start() error { + location, err := prepareCodeServerLocation(c.userName) + if err != nil { + return err + } + + binaryPath := filepath.Join(location, "bin", "code-server") + if _, err := os.Stat(binaryPath); err != nil { + return fmt.Errorf("find binary: %w", err) + } + + homeFolder, err := userHome(c.userName) + if err != nil { + return err + } + // Pin --user-data-dir so the settings install path stays stable regardless + // of XDG_DATA_HOME in the container environment. + userDataDir := filepath.Join(homeFolder, ".local", "share", "code-server") + + return command.StartBackgroundOnce("code-server", func() (*exec.Cmd, error) { + log.Infof("Starting code-server in background...") + // --auth none is safe for clients outside the container because the + // only published path is the devsy port-forward tunnel, which is + // itself authenticated. Intra-container access is still unauthenticated. + runCommand := fmt.Sprintf( + "%s --bind-addr '%s' --user-data-dir '%s' --auth none "+ + "--disable-telemetry --disable-update-check", + binaryPath, c.host+":"+c.port, userDataDir, + ) + return suOrSh(c.userName, runCommand, location), nil + }) +} + +func (c *CodeServer) getReleaseURL() string { + version := Options.GetValue(c.values, VersionOption) + + var url string + if runtime.GOARCH == "arm64" { + url = Options.GetValue(c.values, DownloadArm64Option) + if url == "" { + url = fmt.Sprintf(downloadArm64Template, version, version) + } + } else { + url = Options.GetValue(c.values, DownloadAmd64Option) + if url == "" { + url = fmt.Sprintf(downloadAmd64Template, version, version) + } + } + return url +} + +func (c *CodeServer) installExtensions() error { + if len(c.extensions) == 0 { + return nil + } + + location, err := prepareCodeServerLocation(c.userName) + if err != nil { + return err + } + + out := log.Writer(log.LevelInfo) + defer func() { _ = out.Close() }() + + binaryPath := filepath.Join(location, "bin", "code-server") + var failed []string + for _, extension := range c.extensions { + log.Info("Install extension " + extension + "...") + runCommand := fmt.Sprintf("%s --install-extension '%s'", binaryPath, extension) + cmd := suOrSh(c.userName, runCommand, "") + cmd.Stdout = out + cmd.Stderr = out + if err := cmd.Run(); err != nil { + log.Errorf("failed installing extension: extension=%s, error=%v", extension, err) + failed = append(failed, extension) + } else { + log.Infof("installed extension: extension=%s", extension) + } + } + + // Escalate only on total failure — partial failures stay as logged + // warnings to match openvscode's behavior. + if len(failed) == len(c.extensions) { + return fmt.Errorf("all %d extensions failed to install: %v", len(failed), failed) + } + return nil +} + +// installSettings writes settings.json to code-server's user config dir, +// scoping chown to the code-server subtree to avoid clobbering other ~/.local +// ownership. +func (c *CodeServer) installSettings() error { + if len(c.settings) == 0 { + return nil + } + + homeFolder, err := userHome(c.userName) + if err != nil { + return err + } + codeServerDataDir := filepath.Join(homeFolder, ".local", "share", "code-server") + settingsDir := filepath.Join(codeServerDataDir, "User") + // #nosec G301 -- match openvscode-server convention for parity. + if err := os.MkdirAll(settingsDir, 0o755); err != nil { + return err + } + if err := os.WriteFile( + filepath.Join(settingsDir, "settings.json"), + []byte(c.settings), + 0o600, + ); err != nil { + return err + } + if c.userName != "" { + if err := copy2.ChownR(codeServerDataDir, c.userName); err != nil { + return err + } + } + return nil +} + +// downloadAndExtract fetches the release tarball at url and extracts it under +// location. Cleans up a partial extraction on failure so retries start fresh. +func downloadAndExtract(url, location string) error { + resp, err := devsyhttp.GetHTTPClient().Get(url) // #nosec G107 -- URL comes from VersionOption. + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("download code-server: %s returned %s", url, resp.Status) + } + + if err := extract.Extract(resp.Body, location, extract.StripLevels(1)); err != nil { + if rmErr := os.RemoveAll(location); rmErr != nil { + log.Warnf("cleanup partial install: path=%s err=%v", location, rmErr) + } + return fmt.Errorf("extract code-server: %w", err) + } + return nil +} + +// suOrSh builds an *exec.Cmd that runs runCommand either as the unprivileged +// user (via `su -c`) or as the current user (via `sh -c`). All arg +// elements are constants from this package — runCommand itself is built from +// constants plus internal values, not user-controlled binary paths. +func suOrSh(userName, runCommand, workingDir string) *exec.Cmd { + var cmd *exec.Cmd + if userName != "" { + // #nosec G204 -- args are fixed constants; runCommand is internally built. + cmd = exec.Command("su", userName, "-c", runCommand) + } else { + // #nosec G204 -- args are fixed constants; runCommand is internally built. + cmd = exec.Command("sh", "-c", runCommand) + } + if workingDir != "" { + cmd.Dir = workingDir + } + return cmd +} + +func userHome(userName string) (string, error) { + if userName != "" { + return command.GetHome(userName) + } + return util.UserHomeDir() +} + +// prepareCodeServerLocation returns the install dir for code-server, creating +// it if needed. Layout mirrors the upstream tarball after StripLevels(1): +// /.code-server/bin/code-server. +func prepareCodeServerLocation(userName string) (string, error) { + homeFolder, err := userHome(userName) + if err != nil { + return "", err + } + folder := filepath.Join(homeFolder, ".code-server") + // #nosec G301 -- match openvscode-server convention for parity. + if err := os.MkdirAll(folder, 0o755); err != nil { + return "", err + } + return folder, nil +} diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index 6f64d0f0f..173909fc2 100644 --- a/pkg/ide/ideparse/parse.go +++ b/pkg/ide/ideparse/parse.go @@ -11,6 +11,7 @@ import ( "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/ide/codeserver" "github.com/devsy-org/devsy/pkg/ide/fleet" "github.com/devsy-org/devsy/pkg/ide/jetbrains" "github.com/devsy-org/devsy/pkg/ide/jupyter" @@ -31,8 +32,6 @@ type AllowedIDE struct { Icon string `json:"icon,omitempty"` // IconDark holds an image URL that will be displayed in dark mode IconDark string `json:"iconDark,omitempty"` - // Experimental indicates that this IDE is experimental - Experimental bool `json:"experimental,omitempty"` // Group this IDE belongs to, e.g. for navigation Group config.IDEGroup `json:"group,omitempty"` } @@ -61,28 +60,32 @@ var AllowedIDEs = []AllowedIDE{ Group: config.IDEGroupPrimary, }, { - Name: config.IDECursor, - DisplayName: "Cursor", - Options: vscode.Options, - Icon: config.WebsiteAssetsURL + "/cursor.svg", - Experimental: true, - Group: config.IDEGroupPrimary, + Name: config.IDECodeServer, + DisplayName: "code-server", + Options: codeserver.Options, + Icon: "https://raw.githubusercontent.com/coder/code-server/main/src/browser/media/favicon.svg", + Group: config.IDEGroupPrimary, }, { - Name: config.IDEZed, - DisplayName: "Zed", - Options: ide.Options{}, - Icon: config.WebsiteAssetsURL + "/zed.svg", - Experimental: true, - Group: config.IDEGroupPrimary, + Name: config.IDECursor, + DisplayName: "Cursor", + Options: vscode.Options, + Icon: config.WebsiteAssetsURL + "/cursor.svg", + Group: config.IDEGroupPrimary, }, { - Name: config.IDECodium, - DisplayName: "VSCodium", - Options: vscode.Options, - Icon: config.WebsiteAssetsURL + "/codium.svg", - Experimental: true, - Group: config.IDEGroupPrimary, + Name: config.IDEZed, + DisplayName: "Zed", + Options: ide.Options{}, + Icon: config.WebsiteAssetsURL + "/zed.svg", + Group: config.IDEGroupPrimary, + }, + { + Name: config.IDECodium, + DisplayName: "VSCodium", + Options: vscode.Options, + Icon: config.WebsiteAssetsURL + "/codium.svg", + Group: config.IDEGroupPrimary, }, { Name: config.IDEIntellij, @@ -113,12 +116,11 @@ var AllowedIDEs = []AllowedIDE{ Group: config.IDEGroupJetBrains, }, { - Name: config.IDEFleet, - DisplayName: "Fleet", - Options: fleet.Options, - Icon: config.WebsiteAssetsURL + "/fleet.svg", - Experimental: true, - Group: config.IDEGroupJetBrains, + Name: config.IDEFleet, + DisplayName: "Fleet", + Options: fleet.Options, + Icon: config.WebsiteAssetsURL + "/fleet.svg", + Group: config.IDEGroupJetBrains, }, { Name: config.IDEGoland, @@ -163,61 +165,54 @@ var AllowedIDEs = []AllowedIDE{ Group: config.IDEGroupJetBrains, }, { - Name: config.IDEJupyterNotebook, - DisplayName: "Jupyter Notebook", - Options: jupyter.Options, - Icon: config.WebsiteAssetsURL + "/jupyter.svg", - IconDark: config.WebsiteAssetsURL + "/jupyter_dark.svg", - Experimental: true, - Group: config.IDEGroupOther, + Name: config.IDEJupyterNotebook, + DisplayName: "Jupyter Notebook", + Options: jupyter.Options, + Icon: config.WebsiteAssetsURL + "/jupyter.svg", + IconDark: config.WebsiteAssetsURL + "/jupyter_dark.svg", + Group: config.IDEGroupOther, }, { - Name: config.IDEVSCodeInsiders, - DisplayName: "VS Code Insiders", - Options: vscode.Options, - Icon: config.WebsiteAssetsURL + "/vscode_insiders.svg", - Experimental: true, - Group: config.IDEGroupOther, + Name: config.IDEVSCodeInsiders, + DisplayName: "VS Code Insiders", + Options: vscode.Options, + Icon: config.WebsiteAssetsURL + "/vscode_insiders.svg", + Group: config.IDEGroupOther, }, { - Name: config.IDEPositron, - DisplayName: "Positron", - Options: vscode.Options, - Icon: config.WebsiteAssetsURL + "/positron.svg", - Experimental: true, - Group: config.IDEGroupOther, + Name: config.IDEPositron, + DisplayName: "Positron", + Options: vscode.Options, + Icon: config.WebsiteAssetsURL + "/positron.svg", + Group: config.IDEGroupOther, }, { - Name: config.IDERStudio, - DisplayName: "RStudio Server", - Options: rstudio.Options, - Icon: config.WebsiteAssetsURL + "/rstudio.svg", - Experimental: true, - Group: config.IDEGroupOther, + Name: config.IDERStudio, + DisplayName: "RStudio Server", + Options: rstudio.Options, + Icon: config.WebsiteAssetsURL + "/rstudio.svg", + Group: config.IDEGroupOther, }, { - Name: config.IDEWindsurf, - DisplayName: "Windsurf Editor", - Options: vscode.Options, - Icon: config.WebsiteAssetsURL + "/windsurf.svg", - Experimental: true, - Group: config.IDEGroupPrimary, + Name: config.IDEWindsurf, + DisplayName: "Windsurf Editor", + Options: vscode.Options, + Icon: config.WebsiteAssetsURL + "/windsurf.svg", + Group: config.IDEGroupPrimary, }, { - Name: config.IDEAntigravity, - DisplayName: "Google Antigravity", - Options: vscode.Options, - Icon: config.WebsiteAssetsURL + "/antigravity.svg", - Experimental: true, - Group: config.IDEGroupPrimary, + Name: config.IDEAntigravity, + DisplayName: "Google Antigravity", + Options: vscode.Options, + Icon: config.WebsiteAssetsURL + "/antigravity.svg", + Group: config.IDEGroupPrimary, }, { - Name: config.IDEBob, - DisplayName: "IBM Bob", - Options: vscode.Options, - Icon: "https://devsy.sh/assets/bob.svg", - Experimental: true, - Group: config.IDEGroupPrimary, + Name: config.IDEBob, + DisplayName: "IBM Bob", + Options: vscode.Options, + Icon: "https://devsy.sh/assets/bob.svg", + Group: config.IDEGroupPrimary, }, } diff --git a/pkg/ide/opener/browser_tunnel_state.go b/pkg/ide/opener/browser_tunnel_state.go index 513d56852..7e4e13893 100644 --- a/pkg/ide/opener/browser_tunnel_state.go +++ b/pkg/ide/opener/browser_tunnel_state.go @@ -30,6 +30,10 @@ const TunnelLogFileName = "tunnel.log" // duplicating the literal. const LabelVSCodeBrowser = "vscode" +// LabelCodeServer is the TunnelState.Label value used for the code-server +// (coder.com) browser-IDE tunnel. +const LabelCodeServer = "code-server" + // TunnelState describes a running browser tunnel for a workspace. // // CreateTime is the helper process's creation timestamp in milliseconds diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go index 62c32292d..d9c2e25eb 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -14,6 +14,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/gpg" + "github.com/devsy-org/devsy/pkg/ide/codeserver" "github.com/devsy-org/devsy/pkg/ide/fleet" "github.com/devsy-org/devsy/pkg/ide/jetbrains" "github.com/devsy-org/devsy/pkg/ide/jupyter" @@ -71,6 +72,8 @@ func browserIDEOpener( switch ideName { case string(config.IDEOpenVSCode): return openVSCodeBrowser, true + case string(config.IDECodeServer): + return openCodeServerBrowser, true case string(config.IDEJupyterNotebook): return openJupyterBrowser, true case string(config.IDERStudio): @@ -274,84 +277,86 @@ func makeDaemonStartFunc( } } -func openJupyterBrowser( - ctx context.Context, - ideOptions map[string]config.OptionValue, - params IDEParams, -) (string, error) { +// browserIDESpec describes the per-IDE knobs needed to open a browser-based +// IDE through the detached tunnel. The shared openBrowserIDE function below +// consumes one of these to dispatch any of the four browser IDEs. +type browserIDESpec struct { + BindAddrOption string + DefaultPort int + ForwardPorts bool + Label string + LogName string + // TargetURLFn builds the host-side URL once the bound port is known. + // folder is the container workspace folder (relevant for VS Code-style + // IDEs that take it as a query param; jupyter / rstudio ignore it). + TargetURLFn func(port int, folder string) string +} + +func openBrowserIDE(ctx context.Context, params IDEParams, spec browserIDESpec) (string, error) { if params.GPGAgentForwarding { if err := gpg.ForwardAgent(params.Client); err != nil { return "", err } } - addr, jupyterPort, err := ParseAddressAndPort( - jupyter.Options.GetValue(ideOptions, jupyter.BindAddressOption), - jupyter.DefaultServerPort, - ) + addr, port, err := ParseAddressAndPort(spec.BindAddrOption, spec.DefaultPort) if err != nil { return "", err } - targetURL := fmt.Sprintf("http://localhost:%d/lab", jupyterPort) + folder := params.Result.SubstitutionContext.ContainerWorkspaceFolder + targetURL := spec.TargetURLFn(port, folder) openBrowser := params.Launch == LaunchAuto - pkglog.Infof("Starting jupyter notebook in browser mode at %s", targetURL) - extraPorts := []string{fmt.Sprintf("%s:%d", addr, jupyter.DefaultServerPort)} + pkglog.Infof("Starting %s in browser mode at %s", spec.LogName, targetURL) + extraPorts := []string{fmt.Sprintf("%s:%d", addr, spec.DefaultPort)} effectiveURL, err := startDetachedBrowserTunnel(ctx, params, tunnel.BrowserTunnelParams{ DevsyConfig: params.DevsyConfig, Client: params.Client, User: params.User, TargetURL: targetURL, + ForwardPorts: spec.ForwardPorts, ExtraPorts: extraPorts, AuthSockID: params.SSHAuthSockID, GitSSHSigningKey: params.GitSSHSigningKey, - DaemonStartFunc: makeDaemonStartFunc(params, false, extraPorts), - }, browserIDEInvocation{Label: "jupyter", OpenBrowser: openBrowser}) + DaemonStartFunc: makeDaemonStartFunc(params, spec.ForwardPorts, extraPorts), + }, browserIDEInvocation{Label: spec.Label, OpenBrowser: openBrowser}) if err != nil { return "", err } return effectiveURL, nil } -func openRStudioBrowser( +func openJupyterBrowser( ctx context.Context, ideOptions map[string]config.OptionValue, params IDEParams, ) (string, error) { - if params.GPGAgentForwarding { - if err := gpg.ForwardAgent(params.Client); err != nil { - return "", err - } - } - - addr, rsPort, err := ParseAddressAndPort( - rstudio.Options.GetValue(ideOptions, rstudio.BindAddressOption), - rstudio.DefaultServerPort, - ) - if err != nil { - return "", err - } - - targetURL := fmt.Sprintf("http://localhost:%d", rsPort) - openBrowser := params.Launch == LaunchAuto + return openBrowserIDE(ctx, params, browserIDESpec{ + BindAddrOption: jupyter.Options.GetValue(ideOptions, jupyter.BindAddressOption), + DefaultPort: jupyter.DefaultServerPort, + Label: "jupyter", + LogName: "jupyter notebook", + TargetURLFn: func(port int, _ string) string { + return fmt.Sprintf("http://localhost:%d/lab", port) + }, + }) +} - pkglog.Infof("Starting RStudio server in browser mode at %s", targetURL) - extraPorts := []string{fmt.Sprintf("%s:%d", addr, rstudio.DefaultServerPort)} - effectiveURL, err := startDetachedBrowserTunnel(ctx, params, tunnel.BrowserTunnelParams{ - DevsyConfig: params.DevsyConfig, - Client: params.Client, - User: params.User, - TargetURL: targetURL, - ExtraPorts: extraPorts, - AuthSockID: params.SSHAuthSockID, - GitSSHSigningKey: params.GitSSHSigningKey, - DaemonStartFunc: makeDaemonStartFunc(params, false, extraPorts), - }, browserIDEInvocation{Label: "rstudio", OpenBrowser: openBrowser}) - if err != nil { - return "", err - } - return effectiveURL, nil +func openRStudioBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params IDEParams, +) (string, error) { + return openBrowserIDE(ctx, params, browserIDESpec{ + BindAddrOption: rstudio.Options.GetValue(ideOptions, rstudio.BindAddressOption), + DefaultPort: rstudio.DefaultServerPort, + Label: "rstudio", + LogName: "RStudio server", + TargetURLFn: func(port int, _ string) string { + return fmt.Sprintf("http://localhost:%d", port) + }, + }) } func openVSCodeBrowser( @@ -359,45 +364,37 @@ func openVSCodeBrowser( ideOptions map[string]config.OptionValue, params IDEParams, ) (string, error) { - if params.GPGAgentForwarding { - if err := gpg.ForwardAgent(params.Client); err != nil { - return "", err - } - } - - folder := params.Result.SubstitutionContext.ContainerWorkspaceFolder - addr, vscodePort, err := ParseAddressAndPort( - openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption), - openvscode.DefaultVSCodePort, - ) - if err != nil { - return "", err - } - - targetURL := fmt.Sprintf("http://localhost:%d/?folder=%s", vscodePort, folder) - openBrowser := params.Launch == LaunchAuto + return openBrowserIDE(ctx, params, browserIDESpec{ + BindAddrOption: openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption), + DefaultPort: openvscode.DefaultVSCodePort, + ForwardPorts: openvscode.Options.GetValue( + ideOptions, openvscode.ForwardPortsOption, + ) == config.BoolTrue, + Label: LabelVSCodeBrowser, + LogName: "vscode", + TargetURLFn: func(port int, folder string) string { + return fmt.Sprintf("http://localhost:%d/?folder=%s", port, folder) + }, + }) +} - pkglog.Infof("Starting vscode in browser mode at %s", targetURL) - forwardPorts := openvscode.Options.GetValue( - ideOptions, - openvscode.ForwardPortsOption, - ) == config.BoolTrue - extraPorts := []string{fmt.Sprintf("%s:%d", addr, openvscode.DefaultVSCodePort)} - effectiveURL, err := startDetachedBrowserTunnel(ctx, params, tunnel.BrowserTunnelParams{ - DevsyConfig: params.DevsyConfig, - Client: params.Client, - User: params.User, - TargetURL: targetURL, - ForwardPorts: forwardPorts, - ExtraPorts: extraPorts, - AuthSockID: params.SSHAuthSockID, - GitSSHSigningKey: params.GitSSHSigningKey, - DaemonStartFunc: makeDaemonStartFunc(params, forwardPorts, extraPorts), - }, browserIDEInvocation{Label: LabelVSCodeBrowser, OpenBrowser: openBrowser}) - if err != nil { - return "", err - } - return effectiveURL, nil +func openCodeServerBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params IDEParams, +) (string, error) { + return openBrowserIDE(ctx, params, browserIDESpec{ + BindAddrOption: codeserver.Options.GetValue(ideOptions, codeserver.BindAddressOption), + DefaultPort: codeserver.DefaultCodeServerPort, + ForwardPorts: codeserver.Options.GetValue( + ideOptions, codeserver.ForwardPortsOption, + ) == config.BoolTrue, + Label: LabelCodeServer, + LogName: "code-server", + TargetURLFn: func(port int, folder string) string { + return fmt.Sprintf("http://localhost:%d/?folder=%s", port, folder) + }, + }) } func startFleet(ctx context.Context, params IDEParams) (string, error) { diff --git a/pkg/ide/types.go b/pkg/ide/types.go index 41b72ff84..3e35404ff 100644 --- a/pkg/ide/types.go +++ b/pkg/ide/types.go @@ -51,6 +51,8 @@ func ReusesAuthSock(ide string) bool { switch ide { case string(config.IDEOpenVSCode): return true + case string(config.IDECodeServer): + return true case string(config.IDEJupyterNotebook): return true default: diff --git a/pkg/tunnel/services.go b/pkg/tunnel/services.go index cf0b1247a..f4a961930 100644 --- a/pkg/tunnel/services.go +++ b/pkg/tunnel/services.go @@ -19,6 +19,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/gitsshsigning" + "github.com/devsy-org/devsy/pkg/ide/codeserver" "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/netstat" @@ -85,6 +86,7 @@ func createForwarder( } ports := append([]string{}, forwardedPorts...) ports = append(ports, fmt.Sprintf("%d", openvscode.DefaultVSCodePort)) + ports = append(ports, fmt.Sprintf("%d", codeserver.DefaultCodeServerPort)) return newForwarder(opts.ContainerClient, ports, resolver) }