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.
Files changed (32) hide show
  1. {devs_cli-4.0.2/devs_cli.egg-info → devs_cli-4.0.4}/PKG-INFO +1 -1
  2. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/cli.py +70 -23
  3. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/core/integration.py +87 -65
  4. {devs_cli-4.0.2 → devs_cli-4.0.4/devs_cli.egg-info}/PKG-INFO +1 -1
  5. {devs_cli-4.0.2 → devs_cli-4.0.4}/pyproject.toml +1 -1
  6. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_vscode.py +151 -4
  7. {devs_cli-4.0.2 → devs_cli-4.0.4}/LICENSE +0 -0
  8. {devs_cli-4.0.2 → devs_cli-4.0.4}/README.md +0 -0
  9. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/__init__.py +0 -0
  10. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/config.py +0 -0
  11. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/core/__init__.py +0 -0
  12. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/exceptions.py +0 -0
  13. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs/utils/__init__.py +0 -0
  14. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/SOURCES.txt +0 -0
  15. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/dependency_links.txt +0 -0
  16. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/entry_points.txt +0 -0
  17. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/requires.txt +0 -0
  18. {devs_cli-4.0.2 → devs_cli-4.0.4}/devs_cli.egg-info/top_level.txt +0 -0
  19. {devs_cli-4.0.2 → devs_cli-4.0.4}/setup.cfg +0 -0
  20. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli.py +0 -0
  21. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_clean.py +0 -0
  22. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_misc.py +0 -0
  23. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_start.py +0 -0
  24. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_cli_stop.py +0 -0
  25. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_container_manager.py +0 -0
  26. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_e2e.py +0 -0
  27. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_error_parsing.py +0 -0
  28. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_integration.py +0 -0
  29. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_live_mode.py +0 -0
  30. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_project.py +0 -0
  31. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_repo_cache.py +0 -0
  32. {devs_cli-4.0.2 → devs_cli-4.0.4}/tests/test_workspace_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -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
- vscode = VSCodeIntegration(project)
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 = vscode.launch_multiple_devcontainers(
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(self, workspace_dir: Path, dev_name: str, live: bool = False, attach_to_existing: bool = True) -> str:
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
- # Convert workspace path to hex for VS Code URI
78
- workspace_hex = workspace_dir.as_posix().encode('utf-8').hex()
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
- # Always attach to existing container (since we ensure it's running in CLI)
114
- vscode_uri = self.generate_devcontainer_uri(workspace_dir, dev_name, live, attach_to_existing=True)
115
-
116
- console.print(f" 🚀 Opening VS Code for: {dev_name}")
117
-
118
- # Build VS Code command
119
- cmd = ['code']
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('--new-window')
123
-
124
- cmd.extend(['--folder-uri', vscode_uri])
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, # VS Code launch doesn't need debug mode
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
- console.print(f"📂 Opening {len(dev_names)} devcontainers in VS Code for project: {self.project.info.name}")
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(workspace_dir, dev_name, new_window=True, live=live):
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-cli"
7
- version = "4.0.2"
7
+ version = "4.0.4"
8
8
  description = "DevContainer Management Tool - Manage multiple named devcontainers for any project"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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