diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx index 15593b6df..98955d6a5 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx @@ -23,3 +23,28 @@ export const FORM_FIELD_NAMES = { repo_path: 'repo_path', working_dir: 'working_dir', } as const satisfies Record; + +export const IDE_OPTIONS = [ + { + label: 'Cursor', + value: 'cursor', + }, + { + label: 'VS Code', + value: 'vscode', + }, + { + label: 'Windsurf', + value: 'windsurf', + }, +] as const; + +export const IDE_DISPLAY_NAMES: Record = { + cursor: 'Cursor', + vscode: 'VS Code', + windsurf: 'Windsurf', +}; + +export const getIDEDisplayName = (ide: string): string => { + return IDE_DISPLAY_NAMES[ide] || 'IDE'; +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx index 278bc5b3c..af5251376 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx @@ -21,7 +21,7 @@ import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAler import { useGenerateYaml } from './hooks/useGenerateYaml'; import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; -import { FORM_FIELD_NAMES } from './constants'; +import { FORM_FIELD_NAMES, IDE_OPTIONS } from './constants'; import { IRunEnvironmentFormKeys, IRunEnvironmentFormValues } from './types'; @@ -32,17 +32,6 @@ const namesFieldError = 'Only latin characters, dashes, and digits'; const urlFormatError = 'Only URLs'; const workingDirFormatError = 'Must be an absolute path'; -const ideOptions = [ - { - label: 'Cursor', - value: 'cursor', - }, - { - label: 'VS Code', - value: 'vscode', - }, -]; - enum DockerPythonTabs { DOCKER = 'docker', PYTHON = 'python', @@ -348,7 +337,7 @@ export const CreateDevEnvironment: React.FC = () => { description={t('runs.dev_env.wizard.ide_description')} control={control} name="ide" - options={ideOptions} + options={IDE_OPTIONS} disabled={loading} /> diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/types.ts b/frontend/src/pages/Runs/CreateDevEnvironment/types.ts index 020d40e59..ab504e987 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/types.ts +++ b/frontend/src/pages/Runs/CreateDevEnvironment/types.ts @@ -1,7 +1,7 @@ export interface IRunEnvironmentFormValues { offer: IGpu; name: string; - ide: 'cursor' | 'vscode'; + ide: 'cursor' | 'vscode' | 'windsurf'; config_yaml: string; docker: boolean; image?: string; diff --git a/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx b/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx index ee72ebae9..b2751253a 100644 --- a/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx +++ b/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx @@ -19,6 +19,7 @@ import { import { copyToClipboard } from 'libs'; import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand'; +import { getIDEDisplayName } from 'pages/Runs/CreateDevEnvironment/constants'; import styles from './styles.module.scss'; @@ -52,7 +53,9 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run }) const [attachCommand, copyAttachCommand] = getAttachCommand(run); const [sshCommand, copySSHCommand] = getSSHCommand(run); - const openInIDEUrl = `${run.run_spec.configuration.ide}://vscode-remote/ssh-remote+${run.run_spec.run_name}/${run.run_spec.working_dir || 'workflow'}`; + const configuration = run.run_spec.configuration as TDevEnvironmentConfiguration; + const openInIDEUrl = `${configuration.ide}://vscode-remote/ssh-remote+${run.run_spec.run_name}/${run.run_spec.working_dir || 'workflow'}`; + const ideDisplayName = getIDEDisplayName(configuration.ide); const [configCliCommand, copyCliCommand] = useConfigProjectCliCommand({ projectName: run.project_name }); @@ -74,7 +77,7 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run }) onNavigate={({ detail }) => setActiveStepIndex(detail.requestedStepIndex)} activeStepIndex={activeStepIndex} onSubmit={() => window.open(openInIDEUrl, '_blank')} - submitButtonText="Open in VS Code" + submitButtonText={`Open in ${ideDisplayName}`} allowSkipTo steps={[ { @@ -216,7 +219,7 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run }) }, { title: 'Open', - description: 'After the CLI is attached, you can open the dev environment in VS Code.', + description: `After the CLI is attached, you can open the dev environment in ${ideDisplayName}.`, content: ( diff --git a/frontend/src/types/run.d.ts b/frontend/src/types/run.d.ts index 81146b2f6..452d3a9f4 100644 --- a/frontend/src/types/run.d.ts +++ b/frontend/src/types/run.d.ts @@ -14,7 +14,7 @@ declare type TGPUResources = IGPUSpecRequest & { name?: string | string[]; }; -declare type TIde = 'cursor' | 'vscode'; +declare type TIde = 'cursor' | 'vscode' | 'windsurf'; declare type TVolumeMountPointRequest = { name: string | string[]; diff --git a/src/dstack/_internal/cli/services/configurators/run.py b/src/dstack/_internal/cli/services/configurators/run.py index d025160d0..3d126dd34 100644 --- a/src/dstack/_internal/cli/services/configurators/run.py +++ b/src/dstack/_internal/cli/services/configurators/run.py @@ -1,5 +1,8 @@ import argparse +import json +import os import shlex +import shutil import subprocess import sys import time @@ -677,6 +680,14 @@ def apply_args(self, conf: DevEnvironmentConfiguration, args: argparse.Namespace "Fix by opening [code]Command Palette[/code], executing [code]Shell Command: " "Install 'cursor' command in PATH[/code], and restarting terminal.[/]\n" ) + if conf.ide == "windsurf" and conf.version is None: + conf.version = _detect_windsurf_version() + if conf.version is None: + console.print( + "[secondary]Unable to detect the Windsurf version and pre-install extensions. " + "Fix by opening [code]Command Palette[/code], executing [code]Shell Command: " + "Install 'surf' command in PATH[/code], and restarting terminal.[/]\n" + ) class ServiceConfigurator(RunWithCommandsConfiguratorMixin, BaseRunConfigurator): @@ -730,6 +741,53 @@ def _detect_cursor_version(exe: str = "cursor") -> Optional[str]: return None +def _detect_windsurf_version(exe: str = "windsurf") -> Optional[str]: + """ + Detects the installed Windsurf product version and commit hash. + Returns string in format 'version@commit' (e.g., '1.13.5@97d7a...') or None. + """ + # 1. Locate executable in PATH + cmd_path = shutil.which(exe) + if not cmd_path: + return None + + try: + # 2. Resolve symlinks to find the actual installation directory + current_dir = os.path.dirname(os.path.realpath(cmd_path)) + + # 3. Walk up directory tree to find 'resources/app/product.json' + # Covers Linux (/opt/...), macOS (Contents/Resources/...), and Windows + for _ in range(6): + # Check standard lowercase and macOS TitleCase + for resource_folder in ["resources", "Resources"]: + json_path = os.path.join(current_dir, resource_folder, "app", "product.json") + + if os.path.exists(json_path): + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + # Key 'windsurfVersion' is the product version (1.13.5) + # Key 'version' is the base VS Code version (1.9x) + ver = data.get("windsurfVersion") + commit = data.get("commit") + + if ver and commit: + return f"{ver}@{commit}" + except (OSError, json.JSONDecodeError): + continue + + # Move up one directory level + parent = os.path.dirname(current_dir) + if parent == current_dir: # Reached filesystem root + break + current_dir = parent + + except Exception: + return None + + return None + + def _print_service_urls(run: Run) -> None: if run._run.run_spec.configuration.type != RunConfigurationType.SERVICE.value: return diff --git a/src/dstack/_internal/core/models/configurations.py b/src/dstack/_internal/core/models/configurations.py index 9c4415556..4558aebb1 100644 --- a/src/dstack/_internal/core/models/configurations.py +++ b/src/dstack/_internal/core/models/configurations.py @@ -619,10 +619,17 @@ def check_image_or_commands_present(cls, values): class DevEnvironmentConfigurationParams(CoreModel): ide: Annotated[ - Union[Literal["vscode"], Literal["cursor"]], - Field(description="The IDE to run. Supported values include `vscode` and `cursor`"), + Union[Literal["vscode"], Literal["cursor"], Literal["windsurf"]], + Field( + description="The IDE to run. Supported values include `vscode`, `cursor`, and `windsurf`" + ), ] - version: Annotated[Optional[str], Field(description="The version of the IDE")] = None + version: Annotated[ + Optional[str], + Field( + description="The version of the IDE. For `windsurf`, the version is in the format `version@commit`" + ), + ] = None init: Annotated[CommandsList, Field(description="The shell commands to run on startup")] = [] inactivity_duration: Annotated[ Optional[Union[Literal["off"], int, bool, str]], @@ -649,6 +656,19 @@ def parse_inactivity_duration( return v return None + @root_validator + def validate_windsurf_version_format(cls, values): + ide = values.get("ide") + version = values.get("version") + if ide == "windsurf" and version: + # Validate format: version@commit + if not re.match(r"^.+@[a-f0-9]+$", version): + raise ValueError( + f"Invalid Windsurf version format: `{version}`. " + "Expected format: `version@commit` (e.g., `1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d`)" + ) + return values + class DevEnvironmentConfigurationConfig( ProfileParamsConfig, diff --git a/src/dstack/_internal/server/services/jobs/configurators/dev.py b/src/dstack/_internal/server/services/jobs/configurators/dev.py index 3efea3fa2..da683a60c 100644 --- a/src/dstack/_internal/server/services/jobs/configurators/dev.py +++ b/src/dstack/_internal/server/services/jobs/configurators/dev.py @@ -7,6 +7,7 @@ from dstack._internal.server.services.jobs.configurators.base import JobConfigurator from dstack._internal.server.services.jobs.configurators.extensions.cursor import CursorDesktop from dstack._internal.server.services.jobs.configurators.extensions.vscode import VSCodeDesktop +from dstack._internal.server.services.jobs.configurators.extensions.windsurf import WindsurfDesktop INSTALL_IPYKERNEL = ( "(echo 'pip install ipykernel...' && pip install -q --no-cache-dir ipykernel 2> /dev/null) || " @@ -24,6 +25,8 @@ def __init__(self, run_spec: RunSpec, secrets: Dict[str, str]): __class = VSCodeDesktop elif run_spec.configuration.ide == "cursor": __class = CursorDesktop + elif run_spec.configuration.ide == "windsurf": + __class = WindsurfDesktop else: raise ServerClientError(f"Unsupported IDE: {run_spec.configuration.ide}") self.ide = __class( diff --git a/src/dstack/_internal/server/services/jobs/configurators/extensions/windsurf.py b/src/dstack/_internal/server/services/jobs/configurators/extensions/windsurf.py new file mode 100644 index 000000000..63fee839f --- /dev/null +++ b/src/dstack/_internal/server/services/jobs/configurators/extensions/windsurf.py @@ -0,0 +1,43 @@ +from typing import List, Optional + + +class WindsurfDesktop: + def __init__( + self, + run_name: Optional[str], + version: Optional[str], + extensions: List[str], + ): + self.run_name = run_name + self.version = version + self.extensions = extensions + + def get_install_commands(self) -> List[str]: + commands = [] + if self.version is not None: + version, commit = self.version.split("@") + url = f"https://windsurf-stable.codeiumdata.com/linux-reh-$arch/stable/{commit}/windsurf-reh-linux-$arch-{version}.tar.gz" + archive = "windsurf-reh-linux-$arch.tar.gz" + target = f'~/.windsurf-server/bin/"{commit}"' + commands.extend( + [ + 'if [ $(uname -m) = "aarch64" ]; then arch="arm64"; else arch="x64"; fi', + "mkdir -p /tmp", + f'wget -q --show-progress "{url}" -O "/tmp/{archive}"', + f"mkdir -vp {target}", + f'tar --no-same-owner -xz --strip-components=1 -C {target} -f "/tmp/{archive}"', + f'rm "/tmp/{archive}"', + ] + ) + if self.extensions: + extensions = " ".join(f'--install-extension "{name}"' for name in self.extensions) + commands.append(f'PATH="$PATH":{target}/bin windsurf-server {extensions}') + return commands + + def get_print_readme_commands(self) -> List[str]: + return [ + "echo To open in Windsurf, use link below:", + "echo", + f'echo " windsurf://vscode-remote/ssh-remote+{self.run_name}$DSTACK_WORKING_DIR"', + "echo", + ] diff --git a/src/tests/_internal/core/models/test_configurations.py b/src/tests/_internal/core/models/test_configurations.py index 79007fe19..65eec6264 100644 --- a/src/tests/_internal/core/models/test_configurations.py +++ b/src/tests/_internal/core/models/test_configurations.py @@ -4,7 +4,11 @@ from dstack._internal.core.errors import ConfigurationError from dstack._internal.core.models.common import RegistryAuth -from dstack._internal.core.models.configurations import RepoSpec, parse_run_configuration +from dstack._internal.core.models.configurations import ( + DevEnvironmentConfigurationParams, + RepoSpec, + parse_run_configuration, +) from dstack._internal.core.models.resources import Range @@ -139,3 +143,49 @@ def test_registry_auth_hashable(): """ registry_auth = RegistryAuth(username="username", password="password") hash(registry_auth) + + +class TestDevEnvironmentConfigurationParams: + def test_windsurf_version_valid_format(self): + params = DevEnvironmentConfigurationParams( + ide="windsurf", version="1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d" + ) + assert params.ide == "windsurf" + assert params.version == "1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d" + + def test_windsurf_version_valid_short_commit(self): + params = DevEnvironmentConfigurationParams(ide="windsurf", version="1.0.0@abc123") + assert params.version == "1.0.0@abc123" + + def test_windsurf_version_empty_allowed(self): + params = DevEnvironmentConfigurationParams(ide="windsurf", version=None) + assert params.ide == "windsurf" + assert params.version is None + + def test_windsurf_version_invalid_missing_at(self): + with pytest.raises(ValueError, match="Invalid Windsurf version format"): + DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0") + + def test_windsurf_version_invalid_missing_commit(self): + with pytest.raises(ValueError, match="Invalid Windsurf version format"): + DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0@") + + def test_windsurf_version_invalid_missing_version(self): + with pytest.raises(ValueError, match="Invalid Windsurf version format"): + DevEnvironmentConfigurationParams( + ide="windsurf", version="@8951cd3ad688e789573d7f51750d67ae4a0bea7d" + ) + + def test_windsurf_version_invalid_non_hex_commit(self): + with pytest.raises(ValueError, match="Invalid Windsurf version format"): + DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0@ghijklmnop") + + def test_vscode_version_not_validated(self): + params = DevEnvironmentConfigurationParams(ide="vscode", version="1.80.0") + assert params.ide == "vscode" + assert params.version == "1.80.0" + + def test_cursor_version_not_validated(self): + params = DevEnvironmentConfigurationParams(ide="cursor", version="0.40.0") + assert params.ide == "cursor" + assert params.version == "0.40.0"