diff --git a/.golangci.yaml b/.golangci.yaml index 94eed5599..efaaa2f76 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -37,6 +37,11 @@ linters: - gosec text: "G101" path: "pkg/telemetry/analytics/client.go" + # Per-IDE async commands and setup functions are intentionally parallel + # (one package per browser IDE), so dupl across them is by design. + - linters: + - dupl + path: "cmd/internal/agentcontainer/(codeserver|vscodeweb|openvscode)_async.go" - path: "hack/" linters: - forbidigo diff --git a/SECURITY.md b/SECURITY.md index 99580e1ab..bbf25f5a7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,4 +8,4 @@ See the [install instructions for Devsy](https://devsy.sh/docs/getting-started/i ## Reporting a Vulnerability -Please report vulnerabilities to: [security@devsy.sh](mailto:security@devsy.sh) +Please report vulnerabilities to: [dev@devsy.sh](mailto:dev@devsy.sh) diff --git a/cmd/internal/agentcontainer/container.go b/cmd/internal/agentcontainer/container.go index d750ef01f..d93f8a320 100644 --- a/cmd/internal/agentcontainer/container.go +++ b/cmd/internal/agentcontainer/container.go @@ -25,6 +25,7 @@ func NewContainerCmd(flags *flags.GlobalFlags) *cobra.Command { containerCmd.AddCommand(NewVSCodeAsyncCmd()) containerCmd.AddCommand(NewOpenVSCodeAsyncCmd()) containerCmd.AddCommand(NewCodeServerAsyncCmd()) + containerCmd.AddCommand(NewVSCodeWebAsyncCmd()) containerCmd.AddCommand(NewCredentialsServerCmd(flags)) containerCmd.AddCommand(NewSetupDevsyPlatformAccessCmd(flags)) containerCmd.AddCommand(NewSSHServerCmd(flags)) diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go index d54e676aa..2bb6a36b0 100644 --- a/cmd/internal/agentcontainer/setup.go +++ b/cmd/internal/agentcontainer/setup.go @@ -39,6 +39,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/ide/rstudio" "github.com/devsy-org/devsy/pkg/ide/vscode" + "github.com/devsy-org/devsy/pkg/ide/vscodeweb" "github.com/devsy-org/devsy/pkg/log" provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/ts" @@ -595,6 +596,8 @@ func (cmd *SetupContainerCmd) installIDE( return cmd.setupOpenVSCode(setupInfo, ide.Options) case string(config2.IDECodeServer): return cmd.setupCodeServer(setupInfo, ide.Options) + case string(config2.IDEVSCodeWeb): + return cmd.setupVSCodeWeb(setupInfo, ide.Options) case string(config2.IDEGoland): return jetbrains.NewGolandServer(config.GetRemoteUser(setupInfo), ide.Options). Install(setupInfo) @@ -773,6 +776,7 @@ func (cmd *SetupContainerCmd) setupOpenVSCode( return openVSCode.Start() } +//nolint:dupl // mirrored by setupVSCodeWeb by design (one package per browser IDE). func (cmd *SetupContainerCmd) setupCodeServer( setupInfo *config.Result, ideOptions map[string]config2.OptionValue, @@ -831,6 +835,68 @@ func (cmd *SetupContainerCmd) setupCodeServer( return cs.Start() } +// setupVSCodeWeb intentionally mirrors setupCodeServer; the per-IDE browser +// setup functions are parallel by design (one package per browser IDE). +// +//nolint:dupl // mirrors setupCodeServer by design. +func (cmd *SetupContainerCmd) setupVSCodeWeb( + setupInfo *config.Result, + ideOptions map[string]config2.OptionValue, +) error { + log.Debugf("setup vscode-web") + 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) + vw := vscodeweb.NewVSCodeWeb(vscodeweb.ServerOptions{ + Extensions: vsCodeConfiguration.Extensions, + Settings: settings, + UserName: user, + Host: "0.0.0.0", + Port: strconv.Itoa(vscodeweb.DefaultVSCodeWebPort), + Values: ideOptions, + }) + + if err := vw.Install(); err != nil { + return err + } + + if len(vsCodeConfiguration.Extensions) > 0 { + err := command.StartBackgroundOnce("vscode-web-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, + cmdInternal, + cmdAgent, + cmdContainer, + "vscode-web-async", + "--setup-info", + cmd.SetupInfo, + ), nil + }) + if err != nil { + return fmt.Errorf("install extensions: %w", err) + } + } + + return vw.Start() +} + func configureSystemGitCredentials( ctx context.Context, client tunnel.TunnelClient, diff --git a/cmd/internal/agentcontainer/vscodeweb_async.go b/cmd/internal/agentcontainer/vscodeweb_async.go new file mode 100644 index 000000000..509ffd2f6 --- /dev/null +++ b/cmd/internal/agentcontainer/vscodeweb_async.go @@ -0,0 +1,59 @@ +package agentcontainer + +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/vscodeweb" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +// VSCodeWebAsyncCmd holds the cmd flags. +type VSCodeWebAsyncCmd struct { + *flags.GlobalFlags + + SetupInfo string +} + +// NewVSCodeWebAsyncCmd creates a new command. +func NewVSCodeWebAsyncCmd() *cobra.Command { + cmd := &VSCodeWebAsyncCmd{} + vsCodeWebAsyncCmd := &cobra.Command{ + Use: "vscode-web-async", + Short: "Starts VS Code Web", + Args: cobra.NoArgs, + RunE: cmd.Run, + } + vsCodeWebAsyncCmd.Flags(). + StringVar(&cmd.SetupInfo, "setup-info", "", "The container setup info") + _ = vsCodeWebAsyncCmd.MarkFlagRequired("setup-info") + return vsCodeWebAsyncCmd +} + +// Run runs the command logic. +func (cmd *VSCodeWebAsyncCmd) 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 setupVSCodeWebExtensions(setupInfo) +} + +func setupVSCodeWebExtensions(setupInfo *config.Result) error { + vsCodeConfiguration := config.GetVSCodeConfiguration(setupInfo.MergedConfig) + user := config.GetRemoteUser(setupInfo) + return vscodeweb.NewVSCodeWeb(vscodeweb.ServerOptions{ + Extensions: vsCodeConfiguration.Extensions, + UserName: user, + }).InstallExtensions() +} diff --git a/cmd/pro/start.go b/cmd/pro/start.go index a928d015b..69961e3c7 100644 --- a/cmd/pro/start.go +++ b/cmd/pro/start.go @@ -327,7 +327,7 @@ func (cmd *StartCmd) retryUpgradeAfterPurge( "- via Online Chat: %s\n- via Email: %s\n", greenBold("https://slack.devsy.sh/"), greenBold("https://devsy.sh/"), - greenBold("support@devsy.sh"), + greenBold("dev@devsy.sh"), ), ) } diff --git a/desktop/package.json b/desktop/package.json index 547860d69..f379c8440 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,7 +5,7 @@ "homepage": "https://devsy.sh", "author": { "name": "Devsy", - "email": "support@devsy.sh" + "email": "dev@devsy.sh" }, "type": "module", "main": "dist/main/index.js", diff --git a/desktop/src/renderer/public/icons/ides/vscode-web.svg b/desktop/src/renderer/public/icons/ides/vscode-web.svg new file mode 100644 index 000000000..a9f83279a --- /dev/null +++ b/desktop/src/renderer/public/icons/ides/vscode-web.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 551353664..fcc4d33ed 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -82,6 +82,7 @@ const IDE_GROUPS = [ { value: "none", label: "None", iconName: "none" }, { value: "vscode", label: "VS Code", iconName: "vscode" }, { value: "openvscode", label: "VS Code Browser", iconName: "vscodebrowser" }, + { value: "vscode-web", label: "VS Code Web", iconName: "vscode-web" }, { value: "code-server", label: "code-server", iconName: "code-server" }, { value: "cursor", label: "Cursor", iconName: "cursor" }, { value: "zed", label: "Zed", iconName: "zed" }, diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index 1e2550eb6..d111828ca 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -76,6 +76,7 @@ const IDE_OPTIONS = [ { value: "none", label: "None" }, { value: "vscode", label: "VS Code" }, { value: "openvscode", label: "VS Code Browser" }, + { value: "vscode-web", label: "VS Code Web" }, { value: "cursor", label: "Cursor" }, { value: "zed", label: "Zed" }, { value: "codium", label: "VSCodium" }, diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 0ff0df153..347dfc62d 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -62,6 +62,7 @@ const IDE_OPTIONS = [ { value: "none", label: "None" }, { value: "vscode", label: "VS Code" }, { value: "openvscode", label: "VS Code Browser" }, + { value: "vscode-web", label: "VS Code Web" }, { value: "code-server", label: "code-server" }, { value: "cursor", label: "Cursor" }, { value: "zed", label: "Zed" }, diff --git a/e2e/tests/ide/ide.go b/e2e/tests/ide/ide.go index e54566400..81059b4de 100644 --- a/e2e/tests/ide/ide.go +++ b/e2e/tests/ide/ide.go @@ -43,6 +43,9 @@ var _ = ginkgo.Describe("devsy ide test suite", ginkgo.Label("ide"), ginkgo.Orde err = f.DevsyUpWithIDE(ctx, tempDir, "--ide-launch=skip", "--ide=jupyternotebook") framework.ExpectNoError(err) + err = f.DevsyUpWithIDE(ctx, tempDir, "--ide-launch=skip", "--ide=vscode-web") + framework.ExpectNoError(err) + // TODO: Fix broken IDE // err = f.DevsyUpWithIDE(ctx, tempDir, "--ide-launch=skip", "--ide=fleet") // framework.ExpectNoError(err) diff --git a/pkg/config/ide.go b/pkg/config/ide.go index aa5009f12..3ebf9d541 100644 --- a/pkg/config/ide.go +++ b/pkg/config/ide.go @@ -8,6 +8,7 @@ const ( IDEVSCodeInsiders IDE = "vscode-insiders" IDEOpenVSCode IDE = "openvscode" IDECodeServer IDE = "code-server" + IDEVSCodeWeb IDE = "vscode-web" IDEIntellij IDE = "intellij" IDEGoland IDE = "goland" IDERustRover IDE = "rustrover" diff --git a/pkg/driver/kubernetes/pullsecrets.go b/pkg/driver/kubernetes/pullsecrets.go index 8e239b6f8..4a9b20b98 100644 --- a/pkg/driver/kubernetes/pullsecrets.go +++ b/pkg/driver/kubernetes/pullsecrets.go @@ -119,7 +119,7 @@ func (k *KubernetesDriver) createPullSecret( dockerCredentials *dockercredentials.Credentials, ) error { authToken := dockerCredentials.AuthToken() - email := "noreply@devsy.sh" + email := "dev@devsy.sh" encodedSecretData, err := PreparePullSecretData(dockerCredentials.ServerURL, authToken, email) if err != nil { diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index 329771f0e..f78c7c556 100644 --- a/pkg/ide/ideparse/parse.go +++ b/pkg/ide/ideparse/parse.go @@ -19,6 +19,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/ide/rstudio" "github.com/devsy-org/devsy/pkg/ide/vscode" + "github.com/devsy-org/devsy/pkg/ide/vscodeweb" "github.com/devsy-org/devsy/pkg/provider" ) @@ -67,6 +68,13 @@ var AllowedIDEs = []AllowedIDE{ Icon: "https://raw.githubusercontent.com/coder/code-server/main/src/browser/media/favicon.svg", Group: config.IDEGroupPrimary, }, + { + Name: config.IDEVSCodeWeb, + DisplayName: "VS Code Web", + Options: vscodeweb.Options, + Icon: config.WebsiteAssetsURL + "/vscode-web.svg", + Group: config.IDEGroupPrimary, + }, { Name: config.IDECursor, DisplayName: "Cursor", diff --git a/pkg/ide/opener/browser_tunnel_state.go b/pkg/ide/opener/browser_tunnel_state.go index 7e4e13893..55c508634 100644 --- a/pkg/ide/opener/browser_tunnel_state.go +++ b/pkg/ide/opener/browser_tunnel_state.go @@ -34,6 +34,10 @@ const LabelVSCodeBrowser = "vscode" // (coder.com) browser-IDE tunnel. const LabelCodeServer = "code-server" +// LabelVSCodeWeb is the TunnelState.Label value used for the official VS Code +// (code serve-web) browser-IDE tunnel. +const LabelVSCodeWeb = "vscode-web" + // 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 45da2d112..4e8e7dd7a 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "net" + "net/url" "strconv" "strings" @@ -22,6 +23,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide/openvscode" "github.com/devsy-org/devsy/pkg/ide/rstudio" "github.com/devsy-org/devsy/pkg/ide/vscode" + "github.com/devsy-org/devsy/pkg/ide/vscodeweb" "github.com/devsy-org/devsy/pkg/ide/zed" pkglog "github.com/devsy-org/devsy/pkg/log" open2 "github.com/devsy-org/devsy/pkg/open" @@ -97,6 +99,8 @@ func browserIDEOpener( return openVSCodeBrowser, true case string(config.IDECodeServer): return openCodeServerBrowser, true + case string(config.IDEVSCodeWeb): + return openVSCodeWebBrowser, true case string(config.IDEJupyterNotebook): return openJupyterBrowser, true case string(config.IDEMarimo): @@ -417,7 +421,7 @@ func openVSCodeBrowser( Label: LabelVSCodeBrowser, LogName: "vscode", TargetURLFn: func(port int, folder string) string { - return fmt.Sprintf("http://localhost:%d/?folder=%s", port, folder) + return fmt.Sprintf("http://localhost:%d/?folder=%s", port, url.QueryEscape(folder)) }, }) } @@ -436,7 +440,26 @@ func openCodeServerBrowser( Label: LabelCodeServer, LogName: "code-server", TargetURLFn: func(port int, folder string) string { - return fmt.Sprintf("http://localhost:%d/?folder=%s", port, folder) + return fmt.Sprintf("http://localhost:%d/?folder=%s", port, url.QueryEscape(folder)) + }, + }) +} + +func openVSCodeWebBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params IDEParams, +) (string, error) { + return openBrowserIDE(ctx, params, browserIDESpec{ + BindAddrOption: vscodeweb.Options.GetValue(ideOptions, vscodeweb.BindAddressOption), + DefaultPort: vscodeweb.DefaultVSCodeWebPort, + ForwardPorts: vscodeweb.Options.GetValue( + ideOptions, vscodeweb.ForwardPortsOption, + ) == config.BoolTrue, + Label: LabelVSCodeWeb, + LogName: "vscode-web", + TargetURLFn: func(port int, folder string) string { + return fmt.Sprintf("http://localhost:%d/?folder=%s", port, url.QueryEscape(folder)) }, }) } diff --git a/pkg/ide/types.go b/pkg/ide/types.go index 8d9bd478c..b3f7b1be2 100644 --- a/pkg/ide/types.go +++ b/pkg/ide/types.go @@ -75,6 +75,8 @@ func ReusesAuthSock(ide string) bool { return true case string(config.IDECodeServer): return true + case string(config.IDEVSCodeWeb): + return true case string(config.IDEJupyterNotebook): return true case string(config.IDEMarimo): diff --git a/pkg/ide/vscodeweb/vscodeweb.go b/pkg/ide/vscodeweb/vscodeweb.go new file mode 100644 index 000000000..5eb1a55fc --- /dev/null +++ b/pkg/ide/vscodeweb/vscodeweb.go @@ -0,0 +1,385 @@ +package vscodeweb + +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 for the standalone VS Code CLI. The CLI tarball contains a +// single `code` binary at its root; `code serve-web` fetches the actual server +// payload at runtime on first connect (requires outbound network access). +const ( + downloadAmd64Template = "https://update.code.visualstudio.com/%s/cli-linux-x64/stable" + downloadArm64Template = "https://update.code.visualstudio.com/%s/cli-linux-arm64/stable" +) + +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 VS Code Web to locally, e.g. 0.0.0.0:12345", + Default: "", + }, + VersionOption: { + Name: VersionOption, + Description: "The VS Code version for the serve-web CLI " + + "(a release version like 1.96.4, or 'latest')", + Default: "1.96.4", + }, + DownloadArm64Option: { + Name: DownloadArm64Option, + Description: "The download url for the arm64 VS Code CLI", + }, + DownloadAmd64Option: { + Name: DownloadAmd64Option, + Description: "The download url for the amd64 VS Code CLI", + }, +} + +const archArm64 = "arm64" + +// DefaultVSCodeWebPort sits next to openvscode (10800) and code-server (10801) +// so a host running all three only collides under the FindAvailablePort +// fallback path, not by default. +const DefaultVSCodeWebPort = 10802 + +// ServerOptions configures a VSCodeWeb instance. +type ServerOptions struct { + Extensions []string + Settings string + UserName string + Host string + Port string + Values map[string]config.OptionValue +} + +// VSCodeWeb installs the official VS Code CLI and runs it as `code serve-web` +// inside the workspace, serving the browser IDE backed by the Microsoft +// Marketplace. +type VSCodeWeb struct { + values map[string]config.OptionValue + extensions []string + settings string + userName string + host string + port string +} + +func NewVSCodeWeb(opts ServerOptions) *VSCodeWeb { + host := opts.Host + if host == "" { + host = "0.0.0.0" + } + port := opts.Port + if port == "" { + port = strconv.Itoa(DefaultVSCodeWebPort) + } + return &VSCodeWeb{ + values: opts.Values, + extensions: opts.Extensions, + settings: opts.Settings, + userName: opts.UserName, + host: host, + port: port, + } +} + +// Install downloads the VS Code CLI tarball, extracts the `code` binary under +// /.vscode-web, and writes settings.json. The download is skipped only +// when the existing install already matches the requested release (tracked via +// a marker file), so changing VERSION / DOWNLOAD_* triggers a reinstall. +// settings.json is rewritten on every call so workspace settings stay current. +func (v *VSCodeWeb) Install() error { + location, err := prepareVSCodeWebLocation(v.userName) + if err != nil { + return err + } + + releaseURL := v.getReleaseURL() + if !v.isInstalled(location, releaseURL) { + vscode.InstallAPKRequirements() + + if err := downloadAndExtract(releaseURL, location); err != nil { + return err + } + + if err := writeReleaseMarker(location, releaseURL); err != nil { + return fmt.Errorf("record release marker: %w", err) + } + } + + if err := v.installSettings(); err != nil { + return fmt.Errorf("install settings: %w", err) + } + + if v.userName != "" { + if err := copy2.ChownR(location, v.userName); err != nil { + return fmt.Errorf("chown: %w", err) + } + } + + return nil +} + +// InstallExtensions installs each requested extension via the VS Code CLI. +// Partial failures are logged and tolerated; an aggregated error is returned +// only when every requested extension fails to install. +func (v *VSCodeWeb) InstallExtensions() error { + if err := v.installExtensions(); err != nil { + return fmt.Errorf("install extensions: %w", err) + } + return nil +} + +// Start launches `code serve-web` as a background process bound to host:port. +// Idempotent via command.StartBackgroundOnce. --accept-server-license-terms is +// required so the background process never blocks on a license prompt. +func (v *VSCodeWeb) Start() error { + location, err := prepareVSCodeWebLocation(v.userName) + if err != nil { + return err + } + + binary := binaryPath(location) + if _, err := os.Stat(binary); err != nil { + return fmt.Errorf("find binary: %w", err) + } + + return command.StartBackgroundOnce("vscode-web", func() (*exec.Cmd, error) { + log.Infof("Starting VS Code Web (serve-web) in background") + // serve-web downloads the VS Code server payload on the first browser + // connect, so the container needs outbound network access. Surfaced + // here because the fetch happens inside the detached process and a + // blocked connection would otherwise look like an unexplained hang. + log.Infof( + "VS Code Web fetches its server on first connect; the container needs " + + "outbound access to update.code.visualstudio.com", + ) + // --without-connection-token: the only published path is the devsy + // port-forward tunnel, which is itself authenticated. + runCommand := fmt.Sprintf( + "%s serve-web --without-connection-token --accept-server-license-terms "+ + "--host %q --port %q --server-data-dir %q", + binary, v.host, v.port, serverDataDir(location), + ) + return suOrSh(v.userName, runCommand, location), nil + }) +} + +// isInstalled reports whether the `code` binary exists and was installed from +// the same release URL currently requested. A missing or mismatched marker +// forces a reinstall so VERSION / DOWNLOAD_* overrides take effect. +func (v *VSCodeWeb) isInstalled(location, releaseURL string) bool { + if _, err := os.Stat(binaryPath(location)); err != nil { + return false + } + recorded, err := os.ReadFile(releaseMarkerPath(location)) + if err != nil { + return false + } + return string(recorded) == releaseURL +} + +func (v *VSCodeWeb) getReleaseURL() string { + version := Options.GetValue(v.values, VersionOption) + + var url string + if runtime.GOARCH == archArm64 { + url = Options.GetValue(v.values, DownloadArm64Option) + if url == "" { + url = fmt.Sprintf(downloadArm64Template, version) + } + } else { + url = Options.GetValue(v.values, DownloadAmd64Option) + if url == "" { + url = fmt.Sprintf(downloadAmd64Template, version) + } + } + return url +} + +func (v *VSCodeWeb) installExtensions() error { + if len(v.extensions) == 0 { + return nil + } + + location, err := prepareVSCodeWebLocation(v.userName) + if err != nil { + return err + } + + out := log.Writer(log.LevelInfo) + defer func() { _ = out.Close() }() + + binary := binaryPath(location) + var failed []string + for _, extension := range v.extensions { + log.Infof("Install extension %q", extension) + runCommand := fmt.Sprintf( + "%s serve-web --server-data-dir %q --install-extension %q", + binary, serverDataDir(location), extension, + ) + cmd := suOrSh(v.userName, runCommand, "") + cmd.Stdout = out + cmd.Stderr = out + if err := cmd.Run(); err != nil { + log.Errorf("failed installing extension: extension=%q, error=%v", extension, err) + failed = append(failed, extension) + } else { + log.Infof("installed extension: extension=%q", extension) + } + } + + // Escalate only on total failure — partial failures stay as logged + // warnings to match openvscode's behavior. + if len(failed) == len(v.extensions) { + return fmt.Errorf("all %d extensions failed to install: %v", len(failed), failed) + } + return nil +} + +// installSettings writes settings.json to serve-web's Machine config dir, which +// lives under /data/Machine. +func (v *VSCodeWeb) installSettings() error { + if len(v.settings) == 0 { + return nil + } + + location, err := prepareVSCodeWebLocation(v.userName) + if err != nil { + return err + } + settingsDir := filepath.Join(serverDataDir(location), "data", "Machine") + // #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(v.settings), + 0o600, + ); err != nil { + return err + } + if v.userName != "" { + if err := copy2.ChownR(location, v.userName); err != nil { + return err + } + } + return nil +} + +// downloadAndExtract fetches the CLI tarball at url and extracts it under +// location. The VS Code CLI tarball holds a single `code` binary at its root, +// so no strip levels are applied. 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 VS Code CLI: %s returned %s", url, resp.Status) + } + + if err := extract.Extract(resp.Body, location); err != nil { + if rmErr := os.RemoveAll(location); rmErr != nil { + log.Warnf("cleanup partial install: path=%s err=%v", location, rmErr) + } + return fmt.Errorf("extract VS Code CLI: %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() +} + +// binaryPath is the extracted VS Code CLI binary path. +func binaryPath(location string) string { + return filepath.Join(location, "code") +} + +// serverDataDir is where serve-web stores its server payload and Machine +// settings, kept inside the install dir so it is cleaned up with it. +func serverDataDir(location string) string { + return filepath.Join(location, "server-data") +} + +// releaseMarkerPath holds the release URL the current binary was installed +// from, used to detect VERSION / DOWNLOAD_* changes across runs. +func releaseMarkerPath(location string) string { + return filepath.Join(location, ".release") +} + +func writeReleaseMarker(location, releaseURL string) error { + return os.WriteFile(releaseMarkerPath(location), []byte(releaseURL), 0o600) +} + +// prepareVSCodeWebLocation returns the install dir for VS Code Web, creating it +// if needed. It is deliberately distinct from ~/.vscode-server (used by the +// desktop VS Code remote server, whose binary is itself named code-server). +func prepareVSCodeWebLocation(userName string) (string, error) { + homeFolder, err := userHome(userName) + if err != nil { + return "", err + } + folder := filepath.Join(homeFolder, ".vscode-web") + // #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/vscodeweb/vscodeweb_test.go b/pkg/ide/vscodeweb/vscodeweb_test.go new file mode 100644 index 000000000..927e938f7 --- /dev/null +++ b/pkg/ide/vscodeweb/vscodeweb_test.go @@ -0,0 +1,112 @@ +package vscodeweb + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/devsy-org/devsy/pkg/config" +) + +func TestGetReleaseURLDefaultVersion(t *testing.T) { + v := NewVSCodeWeb(ServerOptions{}) + url := v.getReleaseURL() + + wantVersion := Options[VersionOption].Default + if !strings.Contains(url, wantVersion) { + t.Fatalf("expected url to contain default version %q, got %q", wantVersion, url) + } + if !strings.HasPrefix(url, "https://update.code.visualstudio.com/") { + t.Fatalf("unexpected release host in %q", url) + } + + wantArch := "cli-linux-x64" + if runtime.GOARCH == archArm64 { + wantArch = "cli-linux-arm64" + } + if !strings.Contains(url, wantArch) { + t.Fatalf("expected url to contain %q, got %q", wantArch, url) + } +} + +func TestGetReleaseURLVersionOverride(t *testing.T) { + v := NewVSCodeWeb(ServerOptions{ + Values: map[string]config.OptionValue{ + VersionOption: {Value: "1.99.0"}, + }, + }) + url := v.getReleaseURL() + if !strings.Contains(url, "1.99.0") { + t.Fatalf("expected url to honor VERSION override, got %q", url) + } +} + +func TestGetReleaseURLDownloadOverride(t *testing.T) { + const custom = "https://example.test/my-vscode-cli.tar.gz" + opt := DownloadAmd64Option + if runtime.GOARCH == archArm64 { + opt = DownloadArm64Option + } + v := NewVSCodeWeb(ServerOptions{ + Values: map[string]config.OptionValue{ + opt: {Value: custom}, + }, + }) + if got := v.getReleaseURL(); got != custom { + t.Fatalf("expected explicit download url %q, got %q", custom, got) + } +} + +func TestIsInstalledMatchesReleaseMarker(t *testing.T) { + location := t.TempDir() + v := NewVSCodeWeb(ServerOptions{}) + releaseURL := v.getReleaseURL() + + if v.isInstalled(location, releaseURL) { + t.Fatal("expected not installed when binary is missing") + } + + if err := os.WriteFile(binaryPath(location), []byte("stub"), 0o600); err != nil { + t.Fatal(err) + } + if v.isInstalled(location, releaseURL) { + t.Fatal("expected not installed when release marker is missing") + } + + if err := writeReleaseMarker(location, releaseURL); err != nil { + t.Fatal(err) + } + if !v.isInstalled(location, releaseURL) { + t.Fatal("expected installed when binary and matching marker exist") + } + + if v.isInstalled(location, "https://example.test/other-version") { + t.Fatal("expected reinstall when requested release differs from marker") + } +} + +func TestWriteReleaseMarkerRoundTrip(t *testing.T) { + location := t.TempDir() + const url = "https://update.code.visualstudio.com/1.99.0/cli-linux-x64/stable" + if err := writeReleaseMarker(location, url); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(releaseMarkerPath(location)) // #nosec G304 -- test-controlled temp path + if err != nil { + t.Fatal(err) + } + if string(got) != url { + t.Fatalf("marker mismatch: got %q want %q", string(got), url) + } +} + +func TestNewVSCodeWebDefaults(t *testing.T) { + v := NewVSCodeWeb(ServerOptions{}) + if v.host != "0.0.0.0" { + t.Fatalf("expected default host 0.0.0.0, got %q", v.host) + } + if v.port == "" { + t.Fatalf("expected default port to be set") + } +}