Skip to content

Commit d4680c9

Browse files
[Dev environments] Support windsurf IDE (#3444)
* [Dev environments] Support windsurf IDE #3443 * [Dev environments] Support windsurf IDE #3443 Replaced `surf` with `windsurf`(which is the only one alaviable on Linux)
1 parent f174dff commit d4680c9

File tree

10 files changed

+214
-23
lines changed

10 files changed

+214
-23
lines changed

frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,28 @@ export const FORM_FIELD_NAMES = {
2323
repo_path: 'repo_path',
2424
working_dir: 'working_dir',
2525
} as const satisfies Record<IRunEnvironmentFormKeys, IRunEnvironmentFormKeys>;
26+
27+
export const IDE_OPTIONS = [
28+
{
29+
label: 'Cursor',
30+
value: 'cursor',
31+
},
32+
{
33+
label: 'VS Code',
34+
value: 'vscode',
35+
},
36+
{
37+
label: 'Windsurf',
38+
value: 'windsurf',
39+
},
40+
] as const;
41+
42+
export const IDE_DISPLAY_NAMES: Record<string, string> = {
43+
cursor: 'Cursor',
44+
vscode: 'VS Code',
45+
windsurf: 'Windsurf',
46+
};
47+
48+
export const getIDEDisplayName = (ide: string): string => {
49+
return IDE_DISPLAY_NAMES[ide] || 'IDE';
50+
};

frontend/src/pages/Runs/CreateDevEnvironment/index.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAler
2121

2222
import { useGenerateYaml } from './hooks/useGenerateYaml';
2323
import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml';
24-
import { FORM_FIELD_NAMES } from './constants';
24+
import { FORM_FIELD_NAMES, IDE_OPTIONS } from './constants';
2525

2626
import { IRunEnvironmentFormKeys, IRunEnvironmentFormValues } from './types';
2727

@@ -32,17 +32,6 @@ const namesFieldError = 'Only latin characters, dashes, and digits';
3232
const urlFormatError = 'Only URLs';
3333
const workingDirFormatError = 'Must be an absolute path';
3434

35-
const ideOptions = [
36-
{
37-
label: 'Cursor',
38-
value: 'cursor',
39-
},
40-
{
41-
label: 'VS Code',
42-
value: 'vscode',
43-
},
44-
];
45-
4635
enum DockerPythonTabs {
4736
DOCKER = 'docker',
4837
PYTHON = 'python',
@@ -348,7 +337,7 @@ export const CreateDevEnvironment: React.FC = () => {
348337
description={t('runs.dev_env.wizard.ide_description')}
349338
control={control}
350339
name="ide"
351-
options={ideOptions}
340+
options={IDE_OPTIONS}
352341
disabled={loading}
353342
/>
354343

frontend/src/pages/Runs/CreateDevEnvironment/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export interface IRunEnvironmentFormValues {
22
offer: IGpu;
33
name: string;
4-
ide: 'cursor' | 'vscode';
4+
ide: 'cursor' | 'vscode' | 'windsurf';
55
config_yaml: string;
66
docker: boolean;
77
image?: string;

frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { copyToClipboard } from 'libs';
2020

2121
import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand';
22+
import { getIDEDisplayName } from 'pages/Runs/CreateDevEnvironment/constants';
2223

2324
import styles from './styles.module.scss';
2425

@@ -52,7 +53,9 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run })
5253
const [attachCommand, copyAttachCommand] = getAttachCommand(run);
5354
const [sshCommand, copySSHCommand] = getSSHCommand(run);
5455

55-
const openInIDEUrl = `${run.run_spec.configuration.ide}://vscode-remote/ssh-remote+${run.run_spec.run_name}/${run.run_spec.working_dir || 'workflow'}`;
56+
const configuration = run.run_spec.configuration as TDevEnvironmentConfiguration;
57+
const openInIDEUrl = `${configuration.ide}://vscode-remote/ssh-remote+${run.run_spec.run_name}/${run.run_spec.working_dir || 'workflow'}`;
58+
const ideDisplayName = getIDEDisplayName(configuration.ide);
5659

5760
const [configCliCommand, copyCliCommand] = useConfigProjectCliCommand({ projectName: run.project_name });
5861

@@ -74,7 +77,7 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run })
7477
onNavigate={({ detail }) => setActiveStepIndex(detail.requestedStepIndex)}
7578
activeStepIndex={activeStepIndex}
7679
onSubmit={() => window.open(openInIDEUrl, '_blank')}
77-
submitButtonText="Open in VS Code"
80+
submitButtonText={`Open in ${ideDisplayName}`}
7881
allowSkipTo
7982
steps={[
8083
{
@@ -216,15 +219,15 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run })
216219
},
217220
{
218221
title: 'Open',
219-
description: 'After the CLI is attached, you can open the dev environment in VS Code.',
222+
description: `After the CLI is attached, you can open the dev environment in ${ideDisplayName}.`,
220223
content: (
221224
<SpaceBetween size="s">
222225
<Button
223226
variant="primary"
224227
external={true}
225228
onClick={() => window.open(openInIDEUrl, '_blank')}
226229
>
227-
Open in VS Code
230+
Open in {ideDisplayName}
228231
</Button>
229232

230233
<ExpandableSection headerText="Need plain SSH?">

frontend/src/types/run.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ declare type TGPUResources = IGPUSpecRequest & {
1414
name?: string | string[];
1515
};
1616

17-
declare type TIde = 'cursor' | 'vscode';
17+
declare type TIde = 'cursor' | 'vscode' | 'windsurf';
1818

1919
declare type TVolumeMountPointRequest = {
2020
name: string | string[];

src/dstack/_internal/cli/services/configurators/run.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import argparse
2+
import json
3+
import os
24
import shlex
5+
import shutil
36
import subprocess
47
import sys
58
import time
@@ -677,6 +680,14 @@ def apply_args(self, conf: DevEnvironmentConfiguration, args: argparse.Namespace
677680
"Fix by opening [code]Command Palette[/code], executing [code]Shell Command: "
678681
"Install 'cursor' command in PATH[/code], and restarting terminal.[/]\n"
679682
)
683+
if conf.ide == "windsurf" and conf.version is None:
684+
conf.version = _detect_windsurf_version()
685+
if conf.version is None:
686+
console.print(
687+
"[secondary]Unable to detect the Windsurf version and pre-install extensions. "
688+
"Fix by opening [code]Command Palette[/code], executing [code]Shell Command: "
689+
"Install 'surf' command in PATH[/code], and restarting terminal.[/]\n"
690+
)
680691

681692

682693
class ServiceConfigurator(RunWithCommandsConfiguratorMixin, BaseRunConfigurator):
@@ -730,6 +741,53 @@ def _detect_cursor_version(exe: str = "cursor") -> Optional[str]:
730741
return None
731742

732743

744+
def _detect_windsurf_version(exe: str = "windsurf") -> Optional[str]:
745+
"""
746+
Detects the installed Windsurf product version and commit hash.
747+
Returns string in format 'version@commit' (e.g., '1.13.5@97d7a...') or None.
748+
"""
749+
# 1. Locate executable in PATH
750+
cmd_path = shutil.which(exe)
751+
if not cmd_path:
752+
return None
753+
754+
try:
755+
# 2. Resolve symlinks to find the actual installation directory
756+
current_dir = os.path.dirname(os.path.realpath(cmd_path))
757+
758+
# 3. Walk up directory tree to find 'resources/app/product.json'
759+
# Covers Linux (/opt/...), macOS (Contents/Resources/...), and Windows
760+
for _ in range(6):
761+
# Check standard lowercase and macOS TitleCase
762+
for resource_folder in ["resources", "Resources"]:
763+
json_path = os.path.join(current_dir, resource_folder, "app", "product.json")
764+
765+
if os.path.exists(json_path):
766+
try:
767+
with open(json_path, "r", encoding="utf-8") as f:
768+
data = json.load(f)
769+
# Key 'windsurfVersion' is the product version (1.13.5)
770+
# Key 'version' is the base VS Code version (1.9x)
771+
ver = data.get("windsurfVersion")
772+
commit = data.get("commit")
773+
774+
if ver and commit:
775+
return f"{ver}@{commit}"
776+
except (OSError, json.JSONDecodeError):
777+
continue
778+
779+
# Move up one directory level
780+
parent = os.path.dirname(current_dir)
781+
if parent == current_dir: # Reached filesystem root
782+
break
783+
current_dir = parent
784+
785+
except Exception:
786+
return None
787+
788+
return None
789+
790+
733791
def _print_service_urls(run: Run) -> None:
734792
if run._run.run_spec.configuration.type != RunConfigurationType.SERVICE.value:
735793
return

src/dstack/_internal/core/models/configurations.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -619,10 +619,17 @@ def check_image_or_commands_present(cls, values):
619619

620620
class DevEnvironmentConfigurationParams(CoreModel):
621621
ide: Annotated[
622-
Union[Literal["vscode"], Literal["cursor"]],
623-
Field(description="The IDE to run. Supported values include `vscode` and `cursor`"),
622+
Union[Literal["vscode"], Literal["cursor"], Literal["windsurf"]],
623+
Field(
624+
description="The IDE to run. Supported values include `vscode`, `cursor`, and `windsurf`"
625+
),
624626
]
625-
version: Annotated[Optional[str], Field(description="The version of the IDE")] = None
627+
version: Annotated[
628+
Optional[str],
629+
Field(
630+
description="The version of the IDE. For `windsurf`, the version is in the format `version@commit`"
631+
),
632+
] = None
626633
init: Annotated[CommandsList, Field(description="The shell commands to run on startup")] = []
627634
inactivity_duration: Annotated[
628635
Optional[Union[Literal["off"], int, bool, str]],
@@ -649,6 +656,19 @@ def parse_inactivity_duration(
649656
return v
650657
return None
651658

659+
@root_validator
660+
def validate_windsurf_version_format(cls, values):
661+
ide = values.get("ide")
662+
version = values.get("version")
663+
if ide == "windsurf" and version:
664+
# Validate format: version@commit
665+
if not re.match(r"^.+@[a-f0-9]+$", version):
666+
raise ValueError(
667+
f"Invalid Windsurf version format: `{version}`. "
668+
"Expected format: `version@commit` (e.g., `1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d`)"
669+
)
670+
return values
671+
652672

653673
class DevEnvironmentConfigurationConfig(
654674
ProfileParamsConfig,

src/dstack/_internal/server/services/jobs/configurators/dev.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from dstack._internal.server.services.jobs.configurators.base import JobConfigurator
88
from dstack._internal.server.services.jobs.configurators.extensions.cursor import CursorDesktop
99
from dstack._internal.server.services.jobs.configurators.extensions.vscode import VSCodeDesktop
10+
from dstack._internal.server.services.jobs.configurators.extensions.windsurf import WindsurfDesktop
1011

1112
INSTALL_IPYKERNEL = (
1213
"(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]):
2425
__class = VSCodeDesktop
2526
elif run_spec.configuration.ide == "cursor":
2627
__class = CursorDesktop
28+
elif run_spec.configuration.ide == "windsurf":
29+
__class = WindsurfDesktop
2730
else:
2831
raise ServerClientError(f"Unsupported IDE: {run_spec.configuration.ide}")
2932
self.ide = __class(
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import List, Optional
2+
3+
4+
class WindsurfDesktop:
5+
def __init__(
6+
self,
7+
run_name: Optional[str],
8+
version: Optional[str],
9+
extensions: List[str],
10+
):
11+
self.run_name = run_name
12+
self.version = version
13+
self.extensions = extensions
14+
15+
def get_install_commands(self) -> List[str]:
16+
commands = []
17+
if self.version is not None:
18+
version, commit = self.version.split("@")
19+
url = f"https://windsurf-stable.codeiumdata.com/linux-reh-$arch/stable/{commit}/windsurf-reh-linux-$arch-{version}.tar.gz"
20+
archive = "windsurf-reh-linux-$arch.tar.gz"
21+
target = f'~/.windsurf-server/bin/"{commit}"'
22+
commands.extend(
23+
[
24+
'if [ $(uname -m) = "aarch64" ]; then arch="arm64"; else arch="x64"; fi',
25+
"mkdir -p /tmp",
26+
f'wget -q --show-progress "{url}" -O "/tmp/{archive}"',
27+
f"mkdir -vp {target}",
28+
f'tar --no-same-owner -xz --strip-components=1 -C {target} -f "/tmp/{archive}"',
29+
f'rm "/tmp/{archive}"',
30+
]
31+
)
32+
if self.extensions:
33+
extensions = " ".join(f'--install-extension "{name}"' for name in self.extensions)
34+
commands.append(f'PATH="$PATH":{target}/bin windsurf-server {extensions}')
35+
return commands
36+
37+
def get_print_readme_commands(self) -> List[str]:
38+
return [
39+
"echo To open in Windsurf, use link below:",
40+
"echo",
41+
f'echo " windsurf://vscode-remote/ssh-remote+{self.run_name}$DSTACK_WORKING_DIR"',
42+
"echo",
43+
]

src/tests/_internal/core/models/test_configurations.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from dstack._internal.core.errors import ConfigurationError
66
from dstack._internal.core.models.common import RegistryAuth
7-
from dstack._internal.core.models.configurations import RepoSpec, parse_run_configuration
7+
from dstack._internal.core.models.configurations import (
8+
DevEnvironmentConfigurationParams,
9+
RepoSpec,
10+
parse_run_configuration,
11+
)
812
from dstack._internal.core.models.resources import Range
913

1014

@@ -139,3 +143,49 @@ def test_registry_auth_hashable():
139143
"""
140144
registry_auth = RegistryAuth(username="username", password="password")
141145
hash(registry_auth)
146+
147+
148+
class TestDevEnvironmentConfigurationParams:
149+
def test_windsurf_version_valid_format(self):
150+
params = DevEnvironmentConfigurationParams(
151+
ide="windsurf", version="1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d"
152+
)
153+
assert params.ide == "windsurf"
154+
assert params.version == "1.106.0@8951cd3ad688e789573d7f51750d67ae4a0bea7d"
155+
156+
def test_windsurf_version_valid_short_commit(self):
157+
params = DevEnvironmentConfigurationParams(ide="windsurf", version="1.0.0@abc123")
158+
assert params.version == "1.0.0@abc123"
159+
160+
def test_windsurf_version_empty_allowed(self):
161+
params = DevEnvironmentConfigurationParams(ide="windsurf", version=None)
162+
assert params.ide == "windsurf"
163+
assert params.version is None
164+
165+
def test_windsurf_version_invalid_missing_at(self):
166+
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
167+
DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0")
168+
169+
def test_windsurf_version_invalid_missing_commit(self):
170+
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
171+
DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0@")
172+
173+
def test_windsurf_version_invalid_missing_version(self):
174+
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
175+
DevEnvironmentConfigurationParams(
176+
ide="windsurf", version="@8951cd3ad688e789573d7f51750d67ae4a0bea7d"
177+
)
178+
179+
def test_windsurf_version_invalid_non_hex_commit(self):
180+
with pytest.raises(ValueError, match="Invalid Windsurf version format"):
181+
DevEnvironmentConfigurationParams(ide="windsurf", version="1.106.0@ghijklmnop")
182+
183+
def test_vscode_version_not_validated(self):
184+
params = DevEnvironmentConfigurationParams(ide="vscode", version="1.80.0")
185+
assert params.ide == "vscode"
186+
assert params.version == "1.80.0"
187+
188+
def test_cursor_version_not_validated(self):
189+
params = DevEnvironmentConfigurationParams(ide="cursor", version="0.40.0")
190+
assert params.ide == "cursor"
191+
assert params.version == "0.40.0"

0 commit comments

Comments
 (0)