devs-cli 4.0.3__tar.gz → 4.0.7__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.3/devs_cli.egg-info → devs_cli-4.0.7}/PKG-INFO +1 -1
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/cli.py +70 -23
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/core/integration.py +164 -84
- {devs_cli-4.0.3 → devs_cli-4.0.7/devs_cli.egg-info}/PKG-INFO +1 -1
- {devs_cli-4.0.3 → devs_cli-4.0.7}/pyproject.toml +1 -1
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_cli_vscode.py +151 -4
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_integration.py +24 -4
- {devs_cli-4.0.3 → devs_cli-4.0.7}/LICENSE +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/README.md +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/__init__.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/config.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/core/__init__.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/exceptions.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs/utils/__init__.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs_cli.egg-info/SOURCES.txt +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs_cli.egg-info/dependency_links.txt +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs_cli.egg-info/entry_points.txt +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs_cli.egg-info/requires.txt +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/devs_cli.egg-info/top_level.txt +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/setup.cfg +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_cli.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_cli_clean.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_cli_misc.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_cli_start.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_cli_stop.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_container_manager.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_e2e.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_error_parsing.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_live_mode.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_project.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/tests/test_repo_cache.py +0 -0
- {devs_cli-4.0.3 → devs_cli-4.0.7}/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,13 +1,15 @@
|
|
|
1
1
|
"""VS Code and external tool integrations."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
3
5
|
import subprocess
|
|
4
6
|
import time
|
|
5
7
|
from pathlib import Path
|
|
6
|
-
from typing import List
|
|
8
|
+
from typing import List, Optional
|
|
7
9
|
|
|
8
10
|
from rich.console import Console
|
|
9
11
|
|
|
10
|
-
from ..exceptions import VSCodeError
|
|
12
|
+
from ..exceptions import VSCodeError
|
|
11
13
|
from devs_common.core.project import Project
|
|
12
14
|
from devs_common.utils.devcontainer import prepare_devcontainer_environment
|
|
13
15
|
|
|
@@ -24,13 +26,19 @@ class VSCodeIntegration:
|
|
|
24
26
|
project: Project instance
|
|
25
27
|
"""
|
|
26
28
|
self.project = project
|
|
27
|
-
self._check_vscode_cli()
|
|
28
|
-
|
|
29
|
-
def _check_vscode_cli(self) ->
|
|
30
|
-
"""Check if VS Code CLI is available.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
self.code_available = self._check_vscode_cli()
|
|
30
|
+
|
|
31
|
+
def _check_vscode_cli(self) -> bool:
|
|
32
|
+
"""Check if the VS Code 'code' CLI is available.
|
|
33
|
+
|
|
34
|
+
This is intentionally non-fatal: on a remote/headless dev box (e.g. one you
|
|
35
|
+
only ever reach over SSH) there may be no local 'code' command, but it is still
|
|
36
|
+
useful to start the container and print the URI / command you'd run from a
|
|
37
|
+
machine that does have VS Code. Callers consult ``self.code_available`` and fall
|
|
38
|
+
back to printing the command instead of launching.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the 'code' command is available, False otherwise.
|
|
34
42
|
"""
|
|
35
43
|
try:
|
|
36
44
|
result = subprocess.run(
|
|
@@ -39,90 +47,156 @@ class VSCodeIntegration:
|
|
|
39
47
|
text=True,
|
|
40
48
|
check=False
|
|
41
49
|
)
|
|
42
|
-
|
|
43
|
-
raise DependencyError(
|
|
44
|
-
"VS Code 'code' command not found. Make sure VS Code is installed "
|
|
45
|
-
"and the 'code' command is available in your PATH."
|
|
46
|
-
)
|
|
50
|
+
return result.returncode == 0
|
|
47
51
|
except FileNotFoundError:
|
|
48
|
-
|
|
49
|
-
"VS Code 'code' command not found. Make sure VS Code is installed "
|
|
50
|
-
"and the 'code' command is available in your PATH."
|
|
51
|
-
)
|
|
52
|
+
return False
|
|
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
|
+
|
|
102
|
+
def _format_code_command(
|
|
103
|
+
self,
|
|
104
|
+
workspace_dir: Path,
|
|
105
|
+
dev_name: str,
|
|
106
|
+
live: bool,
|
|
107
|
+
new_window: bool,
|
|
108
|
+
ssh_host: Optional[str],
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Build the 'code' command string for opening a container.
|
|
111
|
+
|
|
112
|
+
Returned as a copy/paste-ready, shell-quoted string so it can be printed and
|
|
113
|
+
run from another machine that has VS Code installed.
|
|
114
|
+
"""
|
|
115
|
+
vscode_uri = self.generate_devcontainer_uri(
|
|
116
|
+
workspace_dir, dev_name, live, attach_to_existing=True, ssh_host=ssh_host
|
|
117
|
+
)
|
|
118
|
+
cmd = ["code"]
|
|
119
|
+
if new_window:
|
|
120
|
+
cmd.append("--new-window")
|
|
121
|
+
cmd.extend(["--folder-uri", vscode_uri])
|
|
122
|
+
return " ".join(shlex.quote(part) for part in cmd)
|
|
123
|
+
|
|
124
|
+
def _print_code_commands(
|
|
125
|
+
self,
|
|
126
|
+
workspace_dir: Path,
|
|
127
|
+
dev_name: str,
|
|
128
|
+
live: bool,
|
|
129
|
+
new_window: bool,
|
|
130
|
+
ssh_host: Optional[str],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Print the 'code' command(s) for this container.
|
|
133
|
+
|
|
134
|
+
Always prints the plain (non-SSH) command. When an SSH host is set, the SSH
|
|
135
|
+
form is printed too, so you can copy whichever matches where you're running it.
|
|
136
|
+
"""
|
|
137
|
+
if ssh_host:
|
|
138
|
+
ssh_cmd = self._format_code_command(workspace_dir, dev_name, live, new_window, ssh_host)
|
|
139
|
+
console.print(f" 📋 VS Code command (via SSH: {ssh_host}):")
|
|
140
|
+
console.print(f" {ssh_cmd}")
|
|
141
|
+
|
|
142
|
+
plain_cmd = self._format_code_command(workspace_dir, dev_name, live, new_window, None)
|
|
143
|
+
console.print(f" 📋 VS Code command:")
|
|
144
|
+
console.print(f" {plain_cmd}")
|
|
145
|
+
|
|
91
146
|
def launch_devcontainer(
|
|
92
|
-
self,
|
|
93
|
-
workspace_dir: Path,
|
|
147
|
+
self,
|
|
148
|
+
workspace_dir: Path,
|
|
94
149
|
dev_name: str,
|
|
95
150
|
new_window: bool = True,
|
|
96
|
-
live: bool = False
|
|
151
|
+
live: bool = False,
|
|
152
|
+
ssh_host: Optional[str] = None,
|
|
97
153
|
) -> bool:
|
|
98
154
|
"""Launch a devcontainer in VS Code.
|
|
99
|
-
|
|
155
|
+
|
|
100
156
|
Args:
|
|
101
157
|
workspace_dir: Workspace directory path
|
|
102
|
-
dev_name: Development environment name
|
|
158
|
+
dev_name: Development environment name
|
|
103
159
|
new_window: Whether to open in a new window
|
|
104
160
|
live: Whether to use live mode (mount current directory)
|
|
105
|
-
|
|
161
|
+
ssh_host: If set, connect via Remote-SSH to this host then attach to the container.
|
|
162
|
+
|
|
106
163
|
Returns:
|
|
107
164
|
True if VS Code launched successfully
|
|
108
|
-
|
|
165
|
+
|
|
109
166
|
Raises:
|
|
110
167
|
VSCodeError: If VS Code launch fails
|
|
111
168
|
"""
|
|
112
169
|
try:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
cmd = ['code']
|
|
120
|
-
|
|
170
|
+
vscode_uri = self.generate_devcontainer_uri(
|
|
171
|
+
workspace_dir, dev_name, live, attach_to_existing=True, ssh_host=ssh_host
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
cmd = ["code"]
|
|
175
|
+
|
|
121
176
|
if new_window:
|
|
122
|
-
cmd.append(
|
|
123
|
-
|
|
124
|
-
cmd.extend([
|
|
125
|
-
|
|
177
|
+
cmd.append("--new-window")
|
|
178
|
+
|
|
179
|
+
cmd.extend(["--folder-uri", vscode_uri])
|
|
180
|
+
|
|
181
|
+
# Always print the command(s) so they can be copied and run elsewhere.
|
|
182
|
+
self._print_code_commands(workspace_dir, dev_name, live, new_window, ssh_host)
|
|
183
|
+
|
|
184
|
+
# No local 'code' command (e.g. a headless remote dev box reached over SSH):
|
|
185
|
+
# don't fail — the container is already prepared, so just point to the
|
|
186
|
+
# command printed above and stop here.
|
|
187
|
+
if not self.code_available:
|
|
188
|
+
console.print(
|
|
189
|
+
f" ⚠️ VS Code 'code' command not found here — container is ready, "
|
|
190
|
+
"but VS Code can't be launched from this machine. "
|
|
191
|
+
"Run the command above from a machine with VS Code installed."
|
|
192
|
+
)
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
if ssh_host:
|
|
196
|
+
console.print(f" 🚀 Opening VS Code for: {dev_name} (via SSH: {ssh_host})")
|
|
197
|
+
else:
|
|
198
|
+
console.print(f" 🚀 Opening VS Code for: {dev_name}")
|
|
199
|
+
|
|
126
200
|
# Set environment variables using shared function
|
|
127
201
|
container_workspace_name = self.project.get_workspace_name(dev_name)
|
|
128
202
|
env = prepare_devcontainer_environment(
|
|
@@ -131,74 +205,80 @@ class VSCodeIntegration:
|
|
|
131
205
|
workspace_folder=workspace_dir,
|
|
132
206
|
container_workspace_name=container_workspace_name,
|
|
133
207
|
git_remote_url=self.project.info.git_remote_url,
|
|
134
|
-
debug=False,
|
|
135
|
-
live=live
|
|
208
|
+
debug=False,
|
|
209
|
+
live=live,
|
|
136
210
|
)
|
|
137
|
-
|
|
138
|
-
# Launch VS Code in background
|
|
211
|
+
|
|
139
212
|
process = subprocess.Popen(
|
|
140
213
|
cmd,
|
|
141
214
|
env=env,
|
|
142
215
|
stdout=subprocess.DEVNULL,
|
|
143
216
|
stderr=subprocess.DEVNULL,
|
|
144
|
-
start_new_session=True
|
|
217
|
+
start_new_session=True,
|
|
145
218
|
)
|
|
146
|
-
|
|
147
|
-
# Give it a moment to start
|
|
219
|
+
|
|
148
220
|
time.sleep(1)
|
|
149
|
-
|
|
150
|
-
# Check if process is still running (not immediately failed)
|
|
221
|
+
|
|
151
222
|
if process.poll() is not None and process.returncode != 0:
|
|
152
223
|
raise VSCodeError(f"VS Code process exited with code {process.returncode}")
|
|
153
|
-
|
|
224
|
+
|
|
154
225
|
console.print(f" ✅ Launched VS Code for: {dev_name}")
|
|
155
226
|
return True
|
|
156
|
-
|
|
227
|
+
|
|
157
228
|
except subprocess.SubprocessError as e:
|
|
158
229
|
raise VSCodeError(f"Failed to launch VS Code for {dev_name}: {e}")
|
|
159
230
|
|
|
160
231
|
def launch_multiple_devcontainers(
|
|
161
|
-
self,
|
|
162
|
-
workspace_dirs: List[Path],
|
|
232
|
+
self,
|
|
233
|
+
workspace_dirs: List[Path],
|
|
163
234
|
dev_names: List[str],
|
|
164
235
|
delay_between_windows: float = 2.0,
|
|
165
|
-
live: bool = False
|
|
236
|
+
live: bool = False,
|
|
237
|
+
ssh_host: Optional[str] = None,
|
|
166
238
|
) -> int:
|
|
167
239
|
"""Launch multiple devcontainers in separate VS Code windows.
|
|
168
|
-
|
|
240
|
+
|
|
169
241
|
Args:
|
|
170
242
|
workspace_dirs: List of workspace directory paths
|
|
171
243
|
dev_names: List of development environment names
|
|
172
244
|
delay_between_windows: Delay between opening windows (seconds)
|
|
173
245
|
live: Whether to use live mode (mount current directory)
|
|
174
|
-
|
|
246
|
+
ssh_host: If set, connect via Remote-SSH to this host then attach to each container.
|
|
247
|
+
|
|
175
248
|
Returns:
|
|
176
249
|
Number of successfully launched windows
|
|
177
250
|
"""
|
|
178
251
|
if len(workspace_dirs) != len(dev_names):
|
|
179
252
|
raise VSCodeError("Workspace directories and dev names lists must have same length")
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
253
|
+
|
|
254
|
+
if ssh_host:
|
|
255
|
+
console.print(
|
|
256
|
+
f"📂 Opening {len(dev_names)} devcontainers in VS Code "
|
|
257
|
+
f"(via SSH: {ssh_host}) for project: {self.project.info.name}"
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
console.print(f"📂 Opening {len(dev_names)} devcontainers in VS Code for project: {self.project.info.name}")
|
|
261
|
+
|
|
183
262
|
success_count = 0
|
|
184
|
-
|
|
263
|
+
|
|
185
264
|
for workspace_dir, dev_name in zip(workspace_dirs, dev_names):
|
|
186
265
|
try:
|
|
187
|
-
if self.launch_devcontainer(
|
|
266
|
+
if self.launch_devcontainer(
|
|
267
|
+
workspace_dir, dev_name, new_window=True, live=live, ssh_host=ssh_host
|
|
268
|
+
):
|
|
188
269
|
success_count += 1
|
|
189
|
-
|
|
190
|
-
# Add delay between windows to ensure they open separately
|
|
270
|
+
|
|
191
271
|
if delay_between_windows > 0:
|
|
192
272
|
time.sleep(delay_between_windows)
|
|
193
|
-
|
|
273
|
+
|
|
194
274
|
except VSCodeError as e:
|
|
195
275
|
console.print(f" ❌ Failed to launch {dev_name}: {e}")
|
|
196
276
|
continue
|
|
197
|
-
|
|
198
|
-
if success_count > 0:
|
|
277
|
+
|
|
278
|
+
if success_count > 0 and self.code_available:
|
|
199
279
|
console.print("")
|
|
200
280
|
console.print(f"💡 VS Code windows should open shortly with titles: '<dev-name> - {self.project.info.directory.name}'")
|
|
201
|
-
|
|
281
|
+
|
|
202
282
|
return success_count
|
|
203
283
|
|
|
204
284
|
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'
|
|
@@ -20,12 +20,32 @@ class TestVSCodeIntegration:
|
|
|
20
20
|
assert integration.project == mock_project
|
|
21
21
|
|
|
22
22
|
def test_init_vscode_not_found(self, mock_project):
|
|
23
|
-
"""
|
|
23
|
+
"""VSCodeIntegration is non-fatal when the 'code' command is missing.
|
|
24
|
+
|
|
25
|
+
On a headless remote dev box there may be no local 'code' command; we still
|
|
26
|
+
want to construct the integration (and later print the command) rather than
|
|
27
|
+
abort.
|
|
28
|
+
"""
|
|
29
|
+
with patch('subprocess.run') as mock_run:
|
|
30
|
+
mock_run.side_effect = FileNotFoundError()
|
|
31
|
+
integration = VSCodeIntegration(mock_project)
|
|
32
|
+
assert integration.code_available is False
|
|
33
|
+
|
|
34
|
+
def test_launch_devcontainer_prints_command_when_code_missing(self, mock_project):
|
|
35
|
+
"""When 'code' is unavailable, launch prints the command and reports success."""
|
|
24
36
|
with patch('subprocess.run') as mock_run:
|
|
25
37
|
mock_run.side_effect = FileNotFoundError()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
integration = VSCodeIntegration(mock_project)
|
|
39
|
+
|
|
40
|
+
with patch('subprocess.Popen') as mock_popen:
|
|
41
|
+
result = integration.launch_devcontainer(Path("/tmp/workspace"), "alice")
|
|
42
|
+
|
|
43
|
+
# Treated as handled, and no 'code' process was launched.
|
|
44
|
+
assert result is True
|
|
45
|
+
assert not any(
|
|
46
|
+
c.args and c.args[0] and c.args[0][0] == "code"
|
|
47
|
+
for c in mock_popen.call_args_list
|
|
48
|
+
)
|
|
29
49
|
|
|
30
50
|
def test_generate_devcontainer_uri_attach(self, mock_project):
|
|
31
51
|
"""Test devcontainer URI generation for attaching to existing container."""
|
|
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
|