devs-cli 4.0.2__tar.gz → 4.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devs_cli-4.0.2/devs_cli.egg-info → devs_cli-4.0.4}/PKG-INFO +1 -1
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/cli.py +70 -23
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/core/integration.py +87 -65
- {devs_cli-4.0.2 → devs_cli-4.0.4/devs_cli.egg-info}/PKG-INFO +1 -1
- {devs_cli-4.0.2 → devs_cli-4.0.4}/pyproject.toml +1 -1
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_vscode.py +151 -4
- {devs_cli-4.0.2 → devs_cli-4.0.4}/LICENSE +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/README.md +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/__init__.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/config.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/core/__init__.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/exceptions.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/utils/__init__.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/SOURCES.txt +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/dependency_links.txt +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/entry_points.txt +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/requires.txt +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/top_level.txt +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/setup.cfg +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_clean.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_misc.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_start.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_stop.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_container_manager.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_e2e.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_error_parsing.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_integration.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_live_mode.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_project.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_repo_cache.py +0 -0
- {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_workspace_manager.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
5
|
import subprocess
|
|
6
|
+
import traceback
|
|
6
7
|
from functools import wraps
|
|
7
8
|
from importlib.metadata import version, PackageNotFoundError
|
|
8
9
|
|
|
@@ -239,64 +240,112 @@ def start(dev_names: tuple, rebuild: bool, rebuild_if_changed: bool, live: bool,
|
|
|
239
240
|
@click.option('--delay', default=2.0, help='Delay between opening VS Code windows (seconds)')
|
|
240
241
|
@click.option('--live', is_flag=True, help='Start containers with current directory mounted as workspace')
|
|
241
242
|
@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
|
|
243
|
+
@click.option(
|
|
244
|
+
'--ssh',
|
|
245
|
+
'ssh_host',
|
|
246
|
+
default=None,
|
|
247
|
+
envvar='DEVS_SSH_HOST',
|
|
248
|
+
help=(
|
|
249
|
+
'Attach VS Code to a container ALREADY RUNNING on a remote SSH host '
|
|
250
|
+
'(e.g. Tailscale or any SSH-reachable Docker host). This is connection-only: '
|
|
251
|
+
'it does NOT provision anything. '
|
|
252
|
+
'It does NOT create, start, or sync the container — you must have already '
|
|
253
|
+
'started it on the remote host yourself (e.g. run `devs start` over there). '
|
|
254
|
+
'Only builds the VS Code Remote-SSH + attach URI. '
|
|
255
|
+
'Can also be set via the DEVS_SSH_HOST env var or ssh_host in DEVS.yml.'
|
|
256
|
+
),
|
|
257
|
+
)
|
|
242
258
|
@debug_option
|
|
243
|
-
def vscode(dev_names: tuple, delay: float, live: bool, env: tuple, debug: bool) -> None:
|
|
259
|
+
def vscode(dev_names: tuple, delay: float, live: bool, env: tuple, ssh_host: str, debug: bool) -> None:
|
|
244
260
|
"""Open devcontainers in VS Code.
|
|
245
|
-
|
|
261
|
+
|
|
246
262
|
DEV_NAMES: One or more development environment names to open
|
|
247
|
-
|
|
263
|
+
|
|
248
264
|
Example: devs vscode sally bob
|
|
249
265
|
Example: devs vscode sally --live # Start with current directory mounted
|
|
250
266
|
Example: devs vscode sally --env QUART_PORT=5001
|
|
267
|
+
Example: devs vscode sally --ssh myhost.tailnet.ts.net # Attach to a container ALREADY running on myhost
|
|
251
268
|
"""
|
|
252
269
|
check_dependencies()
|
|
253
270
|
project = get_project()
|
|
254
|
-
|
|
271
|
+
|
|
272
|
+
# If --ssh not given on CLI or env, check DEVS.yml
|
|
273
|
+
if not ssh_host:
|
|
274
|
+
ssh_host = DevsConfigLoader.load_ssh_host(project.info.name) or None
|
|
275
|
+
|
|
276
|
+
vscode_integration = VSCodeIntegration(project)
|
|
277
|
+
|
|
278
|
+
if ssh_host:
|
|
279
|
+
# SSH mode is attach-only: we do NOT provision anything. The container must
|
|
280
|
+
# already be running on the remote host (start it there yourself, e.g. by
|
|
281
|
+
# running `devs start` on the remote machine). We skip ContainerManager /
|
|
282
|
+
# WorkspaceManager entirely and only need the workspace path to construct the
|
|
283
|
+
# URI; derive it from the project name and dev name (no filesystem access needed).
|
|
284
|
+
workspace_dirs = []
|
|
285
|
+
valid_dev_names = []
|
|
286
|
+
for dev_name in dev_names:
|
|
287
|
+
workspace_name = project.get_workspace_name(dev_name)
|
|
288
|
+
# Use a synthetic Path so generate_devcontainer_uri can derive workspace_name.
|
|
289
|
+
# The path itself is never accessed locally in SSH mode.
|
|
290
|
+
workspace_dirs.append(config.workspaces_dir / workspace_name)
|
|
291
|
+
valid_dev_names.append(dev_name)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
success_count = vscode_integration.launch_multiple_devcontainers(
|
|
295
|
+
workspace_dirs,
|
|
296
|
+
valid_dev_names,
|
|
297
|
+
delay_between_windows=delay,
|
|
298
|
+
live=live,
|
|
299
|
+
ssh_host=ssh_host,
|
|
300
|
+
)
|
|
301
|
+
if success_count == 0:
|
|
302
|
+
console.print("❌ Failed to open any VS Code windows")
|
|
303
|
+
except VSCodeError as e:
|
|
304
|
+
console.print(f"❌ VS Code integration error: {e}")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
# Local mode: manage containers and workspaces as normal
|
|
255
308
|
container_manager = ContainerManager(project, config)
|
|
256
309
|
workspace_manager = WorkspaceManager(project, config)
|
|
257
|
-
|
|
258
|
-
|
|
310
|
+
|
|
259
311
|
workspace_dirs = []
|
|
260
312
|
valid_dev_names = []
|
|
261
|
-
|
|
313
|
+
|
|
262
314
|
for dev_name in dev_names:
|
|
263
315
|
console.print(f" Preparing: {dev_name}")
|
|
264
|
-
|
|
265
|
-
# Load environment variables from DEVS.yml and merge with CLI --env flags
|
|
316
|
+
|
|
266
317
|
devs_env = DevsConfigLoader.load_env_vars(dev_name, project.info.name)
|
|
267
318
|
cli_env = parse_env_vars(env) if env else {}
|
|
268
319
|
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
269
|
-
|
|
320
|
+
|
|
270
321
|
if extra_env:
|
|
271
322
|
console.print(f"🔧 Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
272
|
-
|
|
323
|
+
|
|
273
324
|
try:
|
|
274
|
-
# Ensure workspace exists (handles live mode internally)
|
|
275
325
|
workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
|
|
276
|
-
|
|
277
|
-
# Ensure container is running before launching VS Code (no auto-rebuild in CLI)
|
|
326
|
+
|
|
278
327
|
if container_manager.ensure_container_running(dev_name, workspace_dir, check_rebuild=False, debug=debug, live=live, extra_env=extra_env):
|
|
279
328
|
workspace_dirs.append(workspace_dir)
|
|
280
329
|
valid_dev_names.append(dev_name)
|
|
281
330
|
else:
|
|
282
331
|
console.print(f" ❌ Failed to start container for {dev_name}, skipping...")
|
|
283
|
-
|
|
332
|
+
|
|
284
333
|
except (ContainerError, WorkspaceError) as e:
|
|
285
334
|
console.print(f" ❌ Error preparing {dev_name}: {e}")
|
|
286
335
|
continue
|
|
287
|
-
|
|
336
|
+
|
|
288
337
|
if workspace_dirs:
|
|
289
338
|
try:
|
|
290
|
-
success_count =
|
|
291
|
-
workspace_dirs,
|
|
339
|
+
success_count = vscode_integration.launch_multiple_devcontainers(
|
|
340
|
+
workspace_dirs,
|
|
292
341
|
valid_dev_names,
|
|
293
342
|
delay_between_windows=delay,
|
|
294
|
-
live=live
|
|
343
|
+
live=live,
|
|
295
344
|
)
|
|
296
|
-
|
|
345
|
+
|
|
297
346
|
if success_count == 0:
|
|
298
347
|
console.print("❌ Failed to open any VS Code windows")
|
|
299
|
-
|
|
348
|
+
|
|
300
349
|
except VSCodeError as e:
|
|
301
350
|
console.print(f"❌ VS Code integration error: {e}")
|
|
302
351
|
|
|
@@ -459,7 +508,6 @@ def claude(dev_name: str, prompt: str, auth: bool, reset_workspace: bool, live:
|
|
|
459
508
|
except Exception as e:
|
|
460
509
|
console.print(f"❌ Failed to configure Claude authentication: {e}")
|
|
461
510
|
if debug:
|
|
462
|
-
import traceback
|
|
463
511
|
console.print(traceback.format_exc())
|
|
464
512
|
sys.exit(1)
|
|
465
513
|
|
|
@@ -632,7 +680,6 @@ def _handle_codex_auth(api_key: str, debug: bool) -> None:
|
|
|
632
680
|
except Exception as e:
|
|
633
681
|
console.print(f"❌ Failed to configure Codex authentication: {e}")
|
|
634
682
|
if debug:
|
|
635
|
-
import traceback
|
|
636
683
|
console.print(traceback.format_exc())
|
|
637
684
|
sys.exit(1)
|
|
638
685
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""VS Code and external tool integrations."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import subprocess
|
|
4
5
|
import time
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import List
|
|
7
|
+
from typing import List, Optional
|
|
7
8
|
|
|
8
9
|
from rich.console import Console
|
|
9
10
|
|
|
@@ -50,79 +51,94 @@ class VSCodeIntegration:
|
|
|
50
51
|
"and the 'code' command is available in your PATH."
|
|
51
52
|
)
|
|
52
53
|
|
|
53
|
-
def generate_devcontainer_uri(
|
|
54
|
+
def generate_devcontainer_uri(
|
|
55
|
+
self,
|
|
56
|
+
workspace_dir: Path,
|
|
57
|
+
dev_name: str,
|
|
58
|
+
live: bool = False,
|
|
59
|
+
attach_to_existing: bool = True,
|
|
60
|
+
ssh_host: Optional[str] = None,
|
|
61
|
+
) -> str:
|
|
54
62
|
"""Generate VS Code devcontainer URI.
|
|
55
|
-
|
|
63
|
+
|
|
56
64
|
Args:
|
|
57
65
|
workspace_dir: Workspace directory path
|
|
58
66
|
dev_name: Development environment name
|
|
59
67
|
live: Whether to use live mode (mount current directory)
|
|
60
68
|
attach_to_existing: Whether to attach to existing container (vs create new one)
|
|
61
|
-
|
|
69
|
+
ssh_host: If set, generate a Remote-SSH + attached-container URI for this host.
|
|
70
|
+
The host can be a Tailscale MagicDNS name or any SSH-reachable hostname.
|
|
71
|
+
|
|
62
72
|
Returns:
|
|
63
73
|
VS Code devcontainer URI
|
|
64
74
|
"""
|
|
65
75
|
if attach_to_existing:
|
|
66
|
-
# Generate container name to attach to
|
|
67
76
|
container_name = self.project.get_container_name(dev_name)
|
|
68
|
-
# Use attached-container URI format to connect to existing container
|
|
69
|
-
# Encode container name for URI
|
|
70
|
-
container_hex = container_name.encode('utf-8').hex()
|
|
71
|
-
|
|
72
|
-
# Generate workspace path inside container
|
|
73
77
|
workspace_name = workspace_dir.name if live else self.project.get_workspace_name(dev_name)
|
|
78
|
+
|
|
79
|
+
if ssh_host:
|
|
80
|
+
# JSON-encoded container info that includes the SSH host so VS Code's
|
|
81
|
+
# Dev Containers extension connects through Remote-SSH first.
|
|
82
|
+
# Container name is prefixed with "/" as Docker returns it in Names field.
|
|
83
|
+
container_info = {
|
|
84
|
+
"containerName": f"/{container_name}",
|
|
85
|
+
"settings": {"host": f"ssh://{ssh_host}"},
|
|
86
|
+
}
|
|
87
|
+
container_hex = json.dumps(container_info, separators=(",", ":")).encode("utf-8").hex()
|
|
88
|
+
else:
|
|
89
|
+
container_hex = container_name.encode("utf-8").hex()
|
|
90
|
+
|
|
74
91
|
vscode_uri = f"vscode-remote://attached-container+{container_hex}/workspaces/{workspace_name}"
|
|
75
92
|
else:
|
|
76
93
|
# Original behavior: create new container from devcontainer.json
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# Generate workspace name inside container
|
|
81
|
-
# IMPORTANT: In live mode, we must use the actual host folder name (e.g. "workstuff")
|
|
82
|
-
# because devcontainer CLI mounts the host directory directly, preserving its name.
|
|
83
|
-
# VS Code needs to connect to /workspaces/<host-folder-name>, not our constructed name.
|
|
94
|
+
workspace_hex = workspace_dir.as_posix().encode("utf-8").hex()
|
|
95
|
+
# IMPORTANT: In live mode, use the actual host folder name because devcontainer CLI
|
|
96
|
+
# mounts the host directory directly and VS Code must match that path.
|
|
84
97
|
workspace_name = workspace_dir.name if live else self.project.get_workspace_name(dev_name)
|
|
85
|
-
|
|
86
|
-
# Build VS Code devcontainer URI
|
|
87
98
|
vscode_uri = f"vscode-remote://dev-container+{workspace_hex}/workspaces/{workspace_name}"
|
|
88
|
-
|
|
99
|
+
|
|
89
100
|
return vscode_uri
|
|
90
101
|
|
|
91
102
|
def launch_devcontainer(
|
|
92
|
-
self,
|
|
93
|
-
workspace_dir: Path,
|
|
103
|
+
self,
|
|
104
|
+
workspace_dir: Path,
|
|
94
105
|
dev_name: str,
|
|
95
106
|
new_window: bool = True,
|
|
96
|
-
live: bool = False
|
|
107
|
+
live: bool = False,
|
|
108
|
+
ssh_host: Optional[str] = None,
|
|
97
109
|
) -> bool:
|
|
98
110
|
"""Launch a devcontainer in VS Code.
|
|
99
|
-
|
|
111
|
+
|
|
100
112
|
Args:
|
|
101
113
|
workspace_dir: Workspace directory path
|
|
102
|
-
dev_name: Development environment name
|
|
114
|
+
dev_name: Development environment name
|
|
103
115
|
new_window: Whether to open in a new window
|
|
104
116
|
live: Whether to use live mode (mount current directory)
|
|
105
|
-
|
|
117
|
+
ssh_host: If set, connect via Remote-SSH to this host then attach to the container.
|
|
118
|
+
|
|
106
119
|
Returns:
|
|
107
120
|
True if VS Code launched successfully
|
|
108
|
-
|
|
121
|
+
|
|
109
122
|
Raises:
|
|
110
123
|
VSCodeError: If VS Code launch fails
|
|
111
124
|
"""
|
|
112
125
|
try:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
vscode_uri = self.generate_devcontainer_uri(
|
|
127
|
+
workspace_dir, dev_name, live, attach_to_existing=True, ssh_host=ssh_host
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if ssh_host:
|
|
131
|
+
console.print(f" 🚀 Opening VS Code for: {dev_name} (via SSH: {ssh_host})")
|
|
132
|
+
else:
|
|
133
|
+
console.print(f" 🚀 Opening VS Code for: {dev_name}")
|
|
134
|
+
|
|
135
|
+
cmd = ["code"]
|
|
136
|
+
|
|
121
137
|
if new_window:
|
|
122
|
-
cmd.append(
|
|
123
|
-
|
|
124
|
-
cmd.extend([
|
|
125
|
-
|
|
138
|
+
cmd.append("--new-window")
|
|
139
|
+
|
|
140
|
+
cmd.extend(["--folder-uri", vscode_uri])
|
|
141
|
+
|
|
126
142
|
# Set environment variables using shared function
|
|
127
143
|
container_workspace_name = self.project.get_workspace_name(dev_name)
|
|
128
144
|
env = prepare_devcontainer_environment(
|
|
@@ -131,74 +147,80 @@ class VSCodeIntegration:
|
|
|
131
147
|
workspace_folder=workspace_dir,
|
|
132
148
|
container_workspace_name=container_workspace_name,
|
|
133
149
|
git_remote_url=self.project.info.git_remote_url,
|
|
134
|
-
debug=False,
|
|
135
|
-
live=live
|
|
150
|
+
debug=False,
|
|
151
|
+
live=live,
|
|
136
152
|
)
|
|
137
|
-
|
|
138
|
-
# Launch VS Code in background
|
|
153
|
+
|
|
139
154
|
process = subprocess.Popen(
|
|
140
155
|
cmd,
|
|
141
156
|
env=env,
|
|
142
157
|
stdout=subprocess.DEVNULL,
|
|
143
158
|
stderr=subprocess.DEVNULL,
|
|
144
|
-
start_new_session=True
|
|
159
|
+
start_new_session=True,
|
|
145
160
|
)
|
|
146
|
-
|
|
147
|
-
# Give it a moment to start
|
|
161
|
+
|
|
148
162
|
time.sleep(1)
|
|
149
|
-
|
|
150
|
-
# Check if process is still running (not immediately failed)
|
|
163
|
+
|
|
151
164
|
if process.poll() is not None and process.returncode != 0:
|
|
152
165
|
raise VSCodeError(f"VS Code process exited with code {process.returncode}")
|
|
153
|
-
|
|
166
|
+
|
|
154
167
|
console.print(f" ✅ Launched VS Code for: {dev_name}")
|
|
155
168
|
return True
|
|
156
|
-
|
|
169
|
+
|
|
157
170
|
except subprocess.SubprocessError as e:
|
|
158
171
|
raise VSCodeError(f"Failed to launch VS Code for {dev_name}: {e}")
|
|
159
172
|
|
|
160
173
|
def launch_multiple_devcontainers(
|
|
161
|
-
self,
|
|
162
|
-
workspace_dirs: List[Path],
|
|
174
|
+
self,
|
|
175
|
+
workspace_dirs: List[Path],
|
|
163
176
|
dev_names: List[str],
|
|
164
177
|
delay_between_windows: float = 2.0,
|
|
165
|
-
live: bool = False
|
|
178
|
+
live: bool = False,
|
|
179
|
+
ssh_host: Optional[str] = None,
|
|
166
180
|
) -> int:
|
|
167
181
|
"""Launch multiple devcontainers in separate VS Code windows.
|
|
168
|
-
|
|
182
|
+
|
|
169
183
|
Args:
|
|
170
184
|
workspace_dirs: List of workspace directory paths
|
|
171
185
|
dev_names: List of development environment names
|
|
172
186
|
delay_between_windows: Delay between opening windows (seconds)
|
|
173
187
|
live: Whether to use live mode (mount current directory)
|
|
174
|
-
|
|
188
|
+
ssh_host: If set, connect via Remote-SSH to this host then attach to each container.
|
|
189
|
+
|
|
175
190
|
Returns:
|
|
176
191
|
Number of successfully launched windows
|
|
177
192
|
"""
|
|
178
193
|
if len(workspace_dirs) != len(dev_names):
|
|
179
194
|
raise VSCodeError("Workspace directories and dev names lists must have same length")
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
195
|
+
|
|
196
|
+
if ssh_host:
|
|
197
|
+
console.print(
|
|
198
|
+
f"📂 Opening {len(dev_names)} devcontainers in VS Code "
|
|
199
|
+
f"(via SSH: {ssh_host}) for project: {self.project.info.name}"
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
console.print(f"📂 Opening {len(dev_names)} devcontainers in VS Code for project: {self.project.info.name}")
|
|
203
|
+
|
|
183
204
|
success_count = 0
|
|
184
|
-
|
|
205
|
+
|
|
185
206
|
for workspace_dir, dev_name in zip(workspace_dirs, dev_names):
|
|
186
207
|
try:
|
|
187
|
-
if self.launch_devcontainer(
|
|
208
|
+
if self.launch_devcontainer(
|
|
209
|
+
workspace_dir, dev_name, new_window=True, live=live, ssh_host=ssh_host
|
|
210
|
+
):
|
|
188
211
|
success_count += 1
|
|
189
|
-
|
|
190
|
-
# Add delay between windows to ensure they open separately
|
|
212
|
+
|
|
191
213
|
if delay_between_windows > 0:
|
|
192
214
|
time.sleep(delay_between_windows)
|
|
193
|
-
|
|
215
|
+
|
|
194
216
|
except VSCodeError as e:
|
|
195
217
|
console.print(f" ❌ Failed to launch {dev_name}: {e}")
|
|
196
218
|
continue
|
|
197
|
-
|
|
219
|
+
|
|
198
220
|
if success_count > 0:
|
|
199
221
|
console.print("")
|
|
200
222
|
console.print(f"💡 VS Code windows should open shortly with titles: '<dev-name> - {self.project.info.directory.name}'")
|
|
201
|
-
|
|
223
|
+
|
|
202
224
|
return success_count
|
|
203
225
|
|
|
204
226
|
class ExternalToolIntegration:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Integration tests for the 'vscode' command."""
|
|
2
|
+
import json
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from unittest.mock import MagicMock, Mock, patch
|
|
4
5
|
|
|
@@ -6,6 +7,8 @@ import pytest
|
|
|
6
7
|
from click.testing import CliRunner
|
|
7
8
|
|
|
8
9
|
from devs.cli import cli
|
|
10
|
+
from devs.core.integration import VSCodeIntegration
|
|
11
|
+
from devs.exceptions import VSCodeError, WorkspaceError
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
class TestVSCodeCommand:
|
|
@@ -113,8 +116,6 @@ class TestVSCodeCommand:
|
|
|
113
116
|
mock_container_manager_class, mock_get_project,
|
|
114
117
|
cli_runner, temp_project):
|
|
115
118
|
"""Test vscode command when workspace creation fails."""
|
|
116
|
-
from devs.exceptions import WorkspaceError
|
|
117
|
-
|
|
118
119
|
# Setup mocks
|
|
119
120
|
mock_project = Mock()
|
|
120
121
|
mock_project.info.name = "test-org-test-repo"
|
|
@@ -172,8 +173,6 @@ class TestVSCodeCommand:
|
|
|
172
173
|
mock_container_manager_class, mock_get_project,
|
|
173
174
|
cli_runner, temp_project):
|
|
174
175
|
"""Test vscode command when VS Code integration fails."""
|
|
175
|
-
from devs.exceptions import VSCodeError
|
|
176
|
-
|
|
177
176
|
# Setup mocks
|
|
178
177
|
mock_project = Mock()
|
|
179
178
|
mock_project.info.name = "test-org-test-repo"
|
|
@@ -260,3 +259,151 @@ class TestVSCodeCommand:
|
|
|
260
259
|
|
|
261
260
|
# Verify success
|
|
262
261
|
assert result.exit_code == 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class TestVSCodeSSHMode:
|
|
265
|
+
"""Tests for the SSH connection mode (--ssh / DEVS_SSH_HOST)."""
|
|
266
|
+
|
|
267
|
+
@patch('devs.cli.DevsConfigLoader.load_ssh_host', return_value=None)
|
|
268
|
+
@patch('devs.cli.get_project')
|
|
269
|
+
@patch('devs.cli.ContainerManager')
|
|
270
|
+
@patch('devs.cli.WorkspaceManager')
|
|
271
|
+
@patch('devs.cli.VSCodeIntegration')
|
|
272
|
+
def test_ssh_flag_skips_container_management(
|
|
273
|
+
self,
|
|
274
|
+
mock_vscode_class,
|
|
275
|
+
mock_workspace_manager_class,
|
|
276
|
+
mock_container_manager_class,
|
|
277
|
+
mock_get_project,
|
|
278
|
+
mock_load_ssh_host,
|
|
279
|
+
cli_runner,
|
|
280
|
+
temp_project,
|
|
281
|
+
):
|
|
282
|
+
"""When --ssh is given, ContainerManager and WorkspaceManager are not used."""
|
|
283
|
+
mock_project = Mock()
|
|
284
|
+
mock_project.info.name = "test-org-test-repo"
|
|
285
|
+
mock_project.get_workspace_name.return_value = "test-org-test-repo-alice"
|
|
286
|
+
mock_get_project.return_value = mock_project
|
|
287
|
+
|
|
288
|
+
mock_vscode = Mock()
|
|
289
|
+
mock_vscode.launch_multiple_devcontainers.return_value = 1
|
|
290
|
+
mock_vscode_class.return_value = mock_vscode
|
|
291
|
+
|
|
292
|
+
result = cli_runner.invoke(cli, ['vscode', 'alice', '--ssh', 'myhost.ts.net'])
|
|
293
|
+
|
|
294
|
+
assert result.exit_code == 0
|
|
295
|
+
mock_container_manager_class.assert_not_called()
|
|
296
|
+
mock_workspace_manager_class.assert_not_called()
|
|
297
|
+
|
|
298
|
+
@patch('devs.cli.DevsConfigLoader.load_ssh_host', return_value=None)
|
|
299
|
+
@patch('devs.cli.get_project')
|
|
300
|
+
@patch('devs.cli.ContainerManager')
|
|
301
|
+
@patch('devs.cli.WorkspaceManager')
|
|
302
|
+
@patch('devs.cli.VSCodeIntegration')
|
|
303
|
+
def test_ssh_flag_passes_host_to_launch(
|
|
304
|
+
self,
|
|
305
|
+
mock_vscode_class,
|
|
306
|
+
mock_workspace_manager_class,
|
|
307
|
+
mock_container_manager_class,
|
|
308
|
+
mock_get_project,
|
|
309
|
+
mock_load_ssh_host,
|
|
310
|
+
cli_runner,
|
|
311
|
+
temp_project,
|
|
312
|
+
):
|
|
313
|
+
"""--ssh host is forwarded to launch_multiple_devcontainers."""
|
|
314
|
+
mock_project = Mock()
|
|
315
|
+
mock_project.info.name = "test-org-test-repo"
|
|
316
|
+
mock_project.get_workspace_name.return_value = "test-org-test-repo-alice"
|
|
317
|
+
mock_get_project.return_value = mock_project
|
|
318
|
+
|
|
319
|
+
mock_vscode = Mock()
|
|
320
|
+
mock_vscode.launch_multiple_devcontainers.return_value = 1
|
|
321
|
+
mock_vscode_class.return_value = mock_vscode
|
|
322
|
+
|
|
323
|
+
cli_runner.invoke(cli, ['vscode', 'alice', '--ssh', 'dev.example.ts.net'])
|
|
324
|
+
|
|
325
|
+
call_kwargs = mock_vscode.launch_multiple_devcontainers.call_args
|
|
326
|
+
assert call_kwargs.kwargs.get('ssh_host') == 'dev.example.ts.net' or \
|
|
327
|
+
'dev.example.ts.net' in str(call_kwargs)
|
|
328
|
+
|
|
329
|
+
@patch('devs.cli.DevsConfigLoader.load_ssh_host', return_value='devs-yml-host.ts.net')
|
|
330
|
+
@patch('devs.cli.get_project')
|
|
331
|
+
@patch('devs.cli.ContainerManager')
|
|
332
|
+
@patch('devs.cli.WorkspaceManager')
|
|
333
|
+
@patch('devs.cli.VSCodeIntegration')
|
|
334
|
+
def test_ssh_host_from_devs_yml(
|
|
335
|
+
self,
|
|
336
|
+
mock_vscode_class,
|
|
337
|
+
mock_workspace_manager_class,
|
|
338
|
+
mock_container_manager_class,
|
|
339
|
+
mock_get_project,
|
|
340
|
+
mock_load_ssh_host,
|
|
341
|
+
cli_runner,
|
|
342
|
+
temp_project,
|
|
343
|
+
):
|
|
344
|
+
"""When ssh_host is in DEVS.yml and no --ssh flag, SSH mode is used."""
|
|
345
|
+
mock_project = Mock()
|
|
346
|
+
mock_project.info.name = "test-org-test-repo"
|
|
347
|
+
mock_project.get_workspace_name.return_value = "test-org-test-repo-alice"
|
|
348
|
+
mock_get_project.return_value = mock_project
|
|
349
|
+
|
|
350
|
+
mock_vscode = Mock()
|
|
351
|
+
mock_vscode.launch_multiple_devcontainers.return_value = 1
|
|
352
|
+
mock_vscode_class.return_value = mock_vscode
|
|
353
|
+
|
|
354
|
+
result = cli_runner.invoke(cli, ['vscode', 'alice'])
|
|
355
|
+
|
|
356
|
+
assert result.exit_code == 0
|
|
357
|
+
mock_container_manager_class.assert_not_called()
|
|
358
|
+
call_kwargs = mock_vscode.launch_multiple_devcontainers.call_args
|
|
359
|
+
assert call_kwargs.kwargs.get('ssh_host') == 'devs-yml-host.ts.net' or \
|
|
360
|
+
'devs-yml-host.ts.net' in str(call_kwargs)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class TestSSHURIGeneration:
|
|
364
|
+
"""Unit tests for generate_devcontainer_uri with SSH host."""
|
|
365
|
+
|
|
366
|
+
def _make_vsi(self):
|
|
367
|
+
mock_project = Mock()
|
|
368
|
+
mock_project.get_container_name.side_effect = lambda dn, prefix='dev': f'dev-test-org-repo-{dn}'
|
|
369
|
+
mock_project.get_workspace_name.side_effect = lambda dn: f'test-org-repo-{dn}'
|
|
370
|
+
with patch.object(VSCodeIntegration, '_check_vscode_cli', return_value=None):
|
|
371
|
+
vsi = VSCodeIntegration.__new__(VSCodeIntegration)
|
|
372
|
+
vsi.project = mock_project
|
|
373
|
+
return vsi
|
|
374
|
+
|
|
375
|
+
def test_local_uri_format_unchanged(self):
|
|
376
|
+
"""Local URI format (no ssh_host) is unchanged from the existing behaviour."""
|
|
377
|
+
vsi = self._make_vsi()
|
|
378
|
+
workspace_dir = Path('/home/user/.devs/workspaces/test-org-repo-alice')
|
|
379
|
+
uri = vsi.generate_devcontainer_uri(workspace_dir, 'alice')
|
|
380
|
+
hex_part = uri.split('attached-container+')[1].split('/')[0]
|
|
381
|
+
decoded = bytes.fromhex(hex_part).decode('utf-8')
|
|
382
|
+
assert decoded == 'dev-test-org-repo-alice'
|
|
383
|
+
assert '/workspaces/test-org-repo-alice' in uri
|
|
384
|
+
|
|
385
|
+
def test_ssh_uri_contains_json_with_host(self):
|
|
386
|
+
"""SSH URI encodes JSON with containerName (leading /) and settings.host."""
|
|
387
|
+
vsi = self._make_vsi()
|
|
388
|
+
workspace_dir = Path('/home/user/.devs/workspaces/test-org-repo-alice')
|
|
389
|
+
uri = vsi.generate_devcontainer_uri(workspace_dir, 'alice', ssh_host='myhost.ts.net')
|
|
390
|
+
hex_part = uri.split('attached-container+')[1].split('/')[0]
|
|
391
|
+
decoded = json.loads(bytes.fromhex(hex_part).decode('utf-8'))
|
|
392
|
+
assert decoded['containerName'] == '/dev-test-org-repo-alice'
|
|
393
|
+
assert decoded['settings']['host'] == 'ssh://myhost.ts.net'
|
|
394
|
+
|
|
395
|
+
def test_ssh_uri_workspace_path_correct(self):
|
|
396
|
+
"""SSH URI workspace path matches the devs naming convention."""
|
|
397
|
+
vsi = self._make_vsi()
|
|
398
|
+
workspace_dir = Path('/home/user/.devs/workspaces/test-org-repo-alice')
|
|
399
|
+
uri = vsi.generate_devcontainer_uri(workspace_dir, 'alice', ssh_host='myhost.ts.net')
|
|
400
|
+
assert uri.endswith('/workspaces/test-org-repo-alice')
|
|
401
|
+
|
|
402
|
+
def test_ssh_host_with_port_is_preserved(self):
|
|
403
|
+
"""SSH host including a port number is preserved verbatim."""
|
|
404
|
+
vsi = self._make_vsi()
|
|
405
|
+
workspace_dir = Path('/home/user/.devs/workspaces/test-org-repo-alice')
|
|
406
|
+
uri = vsi.generate_devcontainer_uri(workspace_dir, 'alice', ssh_host='myhost.ts.net:2222')
|
|
407
|
+
hex_part = uri.split('attached-container+')[1].split('/')[0]
|
|
408
|
+
decoded = json.loads(bytes.fromhex(hex_part).decode('utf-8'))
|
|
409
|
+
assert decoded['settings']['host'] == 'ssh://myhost.ts.net:2222'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|