devs-cli 4.0.4__tar.gz → 5.0.0__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.4/devs_cli.egg-info → devs_cli-5.0.0}/PKG-INFO +1 -1
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/cli.py +69 -58
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/core/integration.py +159 -52
- {devs_cli-4.0.4 → devs_cli-5.0.0/devs_cli.egg-info}/PKG-INFO +1 -1
- {devs_cli-4.0.4 → devs_cli-5.0.0}/pyproject.toml +1 -1
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_integration.py +24 -4
- {devs_cli-4.0.4 → devs_cli-5.0.0}/LICENSE +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/README.md +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/__init__.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/config.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/core/__init__.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/exceptions.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs/utils/__init__.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs_cli.egg-info/SOURCES.txt +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs_cli.egg-info/dependency_links.txt +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs_cli.egg-info/entry_points.txt +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs_cli.egg-info/requires.txt +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/devs_cli.egg-info/top_level.txt +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/setup.cfg +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_cli.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_cli_clean.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_cli_misc.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_cli_start.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_cli_stop.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_cli_vscode.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_container_manager.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_e2e.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_error_parsing.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_live_mode.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_project.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_repo_cache.py +0 -0
- {devs_cli-4.0.4 → devs_cli-5.0.0}/tests/test_workspace_manager.py +0 -0
|
@@ -246,12 +246,13 @@ def start(dev_names: tuple, rebuild: bool, rebuild_if_changed: bool, live: bool,
|
|
|
246
246
|
default=None,
|
|
247
247
|
envvar='DEVS_SSH_HOST',
|
|
248
248
|
help=(
|
|
249
|
-
'
|
|
250
|
-
'(e.g. Tailscale
|
|
251
|
-
'
|
|
252
|
-
'
|
|
253
|
-
'
|
|
254
|
-
|
|
249
|
+
'Open VS Code via direct Remote-SSH into a container ALREADY RUNNING and '
|
|
250
|
+
'reachable at this SSH host (e.g. its Tailscale name). The container is its '
|
|
251
|
+
'own ssh host, so this opens its /workspaces folder directly — no attach. '
|
|
252
|
+
'Connection-only: it does NOT create, start, or sync the container — start it '
|
|
253
|
+
'yourself first (e.g. `devs start` on the host). '
|
|
254
|
+
'Usually unnecessary: without --ssh, `devs vscode <dev>` auto-discovers the '
|
|
255
|
+
"container's tailnet name via the handshake start-tailscale.sh writes. "
|
|
255
256
|
'Can also be set via the DEVS_SSH_HOST env var or ssh_host in DEVS.yml.'
|
|
256
257
|
),
|
|
257
258
|
)
|
|
@@ -264,7 +265,8 @@ def vscode(dev_names: tuple, delay: float, live: bool, env: tuple, ssh_host: str
|
|
|
264
265
|
Example: devs vscode sally bob
|
|
265
266
|
Example: devs vscode sally --live # Start with current directory mounted
|
|
266
267
|
Example: devs vscode sally --env QUART_PORT=5001
|
|
267
|
-
Example: devs vscode sally
|
|
268
|
+
Example: devs vscode sally # Auto: direct Remote-SSH if sally's container is on the tailnet, else local
|
|
269
|
+
Example: devs vscode sally --ssh devs-myorg-myrepo-sally # Force direct Remote-SSH into that node
|
|
268
270
|
"""
|
|
269
271
|
check_dependencies()
|
|
270
272
|
project = get_project()
|
|
@@ -275,79 +277,88 @@ def vscode(dev_names: tuple, delay: float, live: bool, env: tuple, ssh_host: str
|
|
|
275
277
|
|
|
276
278
|
vscode_integration = VSCodeIntegration(project)
|
|
277
279
|
|
|
280
|
+
# Decide, per dev, whether to open via direct Remote-SSH (the container is its
|
|
281
|
+
# own tailnet ssh host) or to manage it locally. An explicit --ssh / env / DEVS.yml
|
|
282
|
+
# host forces SSH for every dev; otherwise auto-discover via the tailnet handshake
|
|
283
|
+
# that start-tailscale.sh writes (set only when the container is up + Tailscale SSH
|
|
284
|
+
# is on). Devs that don't resolve fall through to normal local mode.
|
|
278
285
|
if ssh_host:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
ssh_hosts = {dev_name: ssh_host for dev_name in dev_names}
|
|
287
|
+
else:
|
|
288
|
+
ssh_hosts = {}
|
|
286
289
|
for dev_name in dev_names:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
290
|
+
host = vscode_integration.resolve_tailnet_ssh_host(dev_name)
|
|
291
|
+
if host:
|
|
292
|
+
ssh_hosts[dev_name] = host
|
|
293
|
+
|
|
294
|
+
ssh_dev_names = [d for d in dev_names if d in ssh_hosts]
|
|
295
|
+
local_dev_names = [d for d in dev_names if d not in ssh_hosts]
|
|
296
|
+
|
|
297
|
+
# --- Direct Remote-SSH (attach-only: container already running on its host) ---
|
|
298
|
+
if ssh_dev_names:
|
|
299
|
+
for dev_name in ssh_dev_names:
|
|
300
|
+
console.print(f" 🔗 {dev_name} → ssh://{ssh_hosts[dev_name]} (direct Remote-SSH into container)")
|
|
301
|
+
# Synthetic workspace Paths: only the name is used to build the URI; the path
|
|
302
|
+
# is never accessed locally in SSH mode.
|
|
303
|
+
workspace_dirs = [config.workspaces_dir / project.get_workspace_name(d) for d in ssh_dev_names]
|
|
293
304
|
try:
|
|
294
305
|
success_count = vscode_integration.launch_multiple_devcontainers(
|
|
295
306
|
workspace_dirs,
|
|
296
|
-
|
|
307
|
+
ssh_dev_names,
|
|
297
308
|
delay_between_windows=delay,
|
|
298
309
|
live=live,
|
|
299
|
-
|
|
310
|
+
ssh_hosts=ssh_hosts,
|
|
300
311
|
)
|
|
301
312
|
if success_count == 0:
|
|
302
|
-
console.print("❌ Failed to open any VS Code windows")
|
|
313
|
+
console.print("❌ Failed to open any VS Code windows (Remote-SSH)")
|
|
303
314
|
except VSCodeError as e:
|
|
304
315
|
console.print(f"❌ VS Code integration error: {e}")
|
|
305
|
-
return
|
|
306
316
|
|
|
307
|
-
# Local mode: manage containers and workspaces as normal
|
|
308
|
-
|
|
309
|
-
|
|
317
|
+
# --- Local mode: manage containers and workspaces as normal ---
|
|
318
|
+
if local_dev_names:
|
|
319
|
+
container_manager = ContainerManager(project, config)
|
|
320
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
310
321
|
|
|
311
|
-
|
|
312
|
-
|
|
322
|
+
workspace_dirs = []
|
|
323
|
+
valid_dev_names = []
|
|
313
324
|
|
|
314
|
-
|
|
315
|
-
|
|
325
|
+
for dev_name in local_dev_names:
|
|
326
|
+
console.print(f" Preparing: {dev_name}")
|
|
316
327
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
328
|
+
devs_env = DevsConfigLoader.load_env_vars(dev_name, project.info.name)
|
|
329
|
+
cli_env = parse_env_vars(env) if env else {}
|
|
330
|
+
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
320
331
|
|
|
321
|
-
|
|
322
|
-
|
|
332
|
+
if extra_env:
|
|
333
|
+
console.print(f"🔧 Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
323
334
|
|
|
324
|
-
|
|
325
|
-
|
|
335
|
+
try:
|
|
336
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
|
|
326
337
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
338
|
+
if container_manager.ensure_container_running(dev_name, workspace_dir, check_rebuild=False, debug=debug, live=live, extra_env=extra_env):
|
|
339
|
+
workspace_dirs.append(workspace_dir)
|
|
340
|
+
valid_dev_names.append(dev_name)
|
|
341
|
+
else:
|
|
342
|
+
console.print(f" ❌ Failed to start container for {dev_name}, skipping...")
|
|
332
343
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
344
|
+
except (ContainerError, WorkspaceError) as e:
|
|
345
|
+
console.print(f" ❌ Error preparing {dev_name}: {e}")
|
|
346
|
+
continue
|
|
336
347
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
348
|
+
if workspace_dirs:
|
|
349
|
+
try:
|
|
350
|
+
success_count = vscode_integration.launch_multiple_devcontainers(
|
|
351
|
+
workspace_dirs,
|
|
352
|
+
valid_dev_names,
|
|
353
|
+
delay_between_windows=delay,
|
|
354
|
+
live=live,
|
|
355
|
+
)
|
|
345
356
|
|
|
346
|
-
|
|
347
|
-
|
|
357
|
+
if success_count == 0:
|
|
358
|
+
console.print("❌ Failed to open any VS Code windows")
|
|
348
359
|
|
|
349
|
-
|
|
350
|
-
|
|
360
|
+
except VSCodeError as e:
|
|
361
|
+
console.print(f"❌ VS Code integration error: {e}")
|
|
351
362
|
|
|
352
363
|
|
|
353
364
|
@cli.command()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""VS Code and external tool integrations."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import shlex
|
|
4
5
|
import subprocess
|
|
5
6
|
import time
|
|
6
7
|
from pathlib import Path
|
|
@@ -8,8 +9,9 @@ from typing import List, Optional
|
|
|
8
9
|
|
|
9
10
|
from rich.console import Console
|
|
10
11
|
|
|
11
|
-
from ..exceptions import VSCodeError
|
|
12
|
+
from ..exceptions import VSCodeError
|
|
12
13
|
from devs_common.core.project import Project
|
|
14
|
+
from devs_common.utils.config_hash import get_env_mount_path
|
|
13
15
|
from devs_common.utils.devcontainer import prepare_devcontainer_environment
|
|
14
16
|
|
|
15
17
|
console = Console()
|
|
@@ -25,13 +27,19 @@ class VSCodeIntegration:
|
|
|
25
27
|
project: Project instance
|
|
26
28
|
"""
|
|
27
29
|
self.project = project
|
|
28
|
-
self._check_vscode_cli()
|
|
29
|
-
|
|
30
|
-
def _check_vscode_cli(self) ->
|
|
31
|
-
"""Check if VS Code CLI is available.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
self.code_available = self._check_vscode_cli()
|
|
31
|
+
|
|
32
|
+
def _check_vscode_cli(self) -> bool:
|
|
33
|
+
"""Check if the VS Code 'code' CLI is available.
|
|
34
|
+
|
|
35
|
+
This is intentionally non-fatal: on a remote/headless dev box (e.g. one you
|
|
36
|
+
only ever reach over SSH) there may be no local 'code' command, but it is still
|
|
37
|
+
useful to start the container and print the URI / command you'd run from a
|
|
38
|
+
machine that does have VS Code. Callers consult ``self.code_available`` and fall
|
|
39
|
+
back to printing the command instead of launching.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if the 'code' command is available, False otherwise.
|
|
35
43
|
"""
|
|
36
44
|
try:
|
|
37
45
|
result = subprocess.run(
|
|
@@ -40,17 +48,45 @@ class VSCodeIntegration:
|
|
|
40
48
|
text=True,
|
|
41
49
|
check=False
|
|
42
50
|
)
|
|
43
|
-
|
|
44
|
-
raise DependencyError(
|
|
45
|
-
"VS Code 'code' command not found. Make sure VS Code is installed "
|
|
46
|
-
"and the 'code' command is available in your PATH."
|
|
47
|
-
)
|
|
51
|
+
return result.returncode == 0
|
|
48
52
|
except FileNotFoundError:
|
|
49
|
-
|
|
50
|
-
"VS Code 'code' command not found. Make sure VS Code is installed "
|
|
51
|
-
"and the 'code' command is available in your PATH."
|
|
52
|
-
)
|
|
53
|
+
return False
|
|
53
54
|
|
|
55
|
+
def resolve_tailnet_ssh_host(self, dev_name: str) -> Optional[str]:
|
|
56
|
+
"""Discover a container's own tailnet SSH name via the writeback handshake.
|
|
57
|
+
|
|
58
|
+
Joins docker <-> tailnet by docker container id, WITHOUT `docker exec`:
|
|
59
|
+
``docker inspect`` the container for its hostname (== its short container id,
|
|
60
|
+
== the ``$HOSTNAME`` start-tailscale.sh keyed its handshake file by), then read
|
|
61
|
+
that file out of the host-shared env mount. Returns the short MagicDNS name
|
|
62
|
+
(e.g. ``devs-acme-app-eamonn-1``) only when the container is up AND Tailscale
|
|
63
|
+
SSH is on; otherwise None (caller falls back to local mode).
|
|
64
|
+
"""
|
|
65
|
+
container_name = self.project.get_container_name(dev_name)
|
|
66
|
+
try:
|
|
67
|
+
inspect = subprocess.run(
|
|
68
|
+
["docker", "inspect", "--format", "{{.Config.Hostname}}", container_name],
|
|
69
|
+
capture_output=True, text=True, check=False,
|
|
70
|
+
)
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
return None # no docker here
|
|
73
|
+
if inspect.returncode != 0:
|
|
74
|
+
return None # container doesn't exist / not inspectable
|
|
75
|
+
container_id = inspect.stdout.strip()
|
|
76
|
+
if not container_id:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
handshake = get_env_mount_path(self.project.info.name) / "ts-nodes" / f"{container_id}.json"
|
|
80
|
+
if not handshake.exists():
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
data = json.loads(handshake.read_text())
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
if not data.get("ssh"):
|
|
87
|
+
return None # on the tailnet but not accepting SSH
|
|
88
|
+
return data.get("short") or None
|
|
89
|
+
|
|
54
90
|
def generate_devcontainer_uri(
|
|
55
91
|
self,
|
|
56
92
|
workspace_dir: Path,
|
|
@@ -66,39 +102,81 @@ class VSCodeIntegration:
|
|
|
66
102
|
dev_name: Development environment name
|
|
67
103
|
live: Whether to use live mode (mount current directory)
|
|
68
104
|
attach_to_existing: Whether to attach to existing container (vs create new one)
|
|
69
|
-
ssh_host: If set, generate a Remote-SSH
|
|
70
|
-
|
|
105
|
+
ssh_host: If set, generate a plain Remote-SSH folder URI for this host.
|
|
106
|
+
In the devs+Tailscale model the container IS its own SSH host
|
|
107
|
+
(you SSH straight into it — there's no separate Docker daemon to
|
|
108
|
+
attach through), so this is NOT an attached-container URI.
|
|
71
109
|
|
|
72
110
|
Returns:
|
|
73
111
|
VS Code devcontainer URI
|
|
74
112
|
"""
|
|
113
|
+
# IMPORTANT: In live mode, use the actual host folder name because devcontainer CLI
|
|
114
|
+
# mounts the host directory directly and VS Code must match that path.
|
|
115
|
+
workspace_name = workspace_dir.name if live else self.project.get_workspace_name(dev_name)
|
|
116
|
+
|
|
117
|
+
if ssh_host:
|
|
118
|
+
# Direct Remote-SSH INTO the container. With Tailscale the container is its
|
|
119
|
+
# own tailnet node running Tailscale SSH, so VS Code opens the in-container
|
|
120
|
+
# workspace folder over SSH — no Docker host, no attach. Wrapping this in an
|
|
121
|
+
# attached-container URI would make VS Code SSH in and then hunt for a Docker
|
|
122
|
+
# daemon that isn't there.
|
|
123
|
+
return f"vscode-remote://ssh-remote+{ssh_host}/workspaces/{workspace_name}"
|
|
124
|
+
|
|
75
125
|
if attach_to_existing:
|
|
76
126
|
container_name = self.project.get_container_name(dev_name)
|
|
77
|
-
|
|
127
|
+
container_hex = container_name.encode("utf-8").hex()
|
|
128
|
+
return f"vscode-remote://attached-container+{container_hex}/workspaces/{workspace_name}"
|
|
78
129
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
130
|
+
# Create new container from devcontainer.json
|
|
131
|
+
workspace_hex = workspace_dir.as_posix().encode("utf-8").hex()
|
|
132
|
+
return f"vscode-remote://dev-container+{workspace_hex}/workspaces/{workspace_name}"
|
|
133
|
+
|
|
134
|
+
def _format_code_command(
|
|
135
|
+
self,
|
|
136
|
+
workspace_dir: Path,
|
|
137
|
+
dev_name: str,
|
|
138
|
+
live: bool,
|
|
139
|
+
new_window: bool,
|
|
140
|
+
ssh_host: Optional[str],
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Build the 'code' command string for opening a container.
|
|
143
|
+
|
|
144
|
+
Returned as a copy/paste-ready, shell-quoted string so it can be printed and
|
|
145
|
+
run from another machine that has VS Code installed.
|
|
146
|
+
"""
|
|
147
|
+
vscode_uri = self.generate_devcontainer_uri(
|
|
148
|
+
workspace_dir, dev_name, live, attach_to_existing=True, ssh_host=ssh_host
|
|
149
|
+
)
|
|
150
|
+
cmd = ["code"]
|
|
151
|
+
if new_window:
|
|
152
|
+
cmd.append("--new-window")
|
|
153
|
+
cmd.extend(["--folder-uri", vscode_uri])
|
|
154
|
+
return " ".join(shlex.quote(part) for part in cmd)
|
|
155
|
+
|
|
156
|
+
def _print_code_commands(
|
|
157
|
+
self,
|
|
158
|
+
workspace_dir: Path,
|
|
159
|
+
dev_name: str,
|
|
160
|
+
live: bool,
|
|
161
|
+
new_window: bool,
|
|
162
|
+
ssh_host: Optional[str],
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Print the 'code' command for this container.
|
|
165
|
+
|
|
166
|
+
When an SSH host is set we print only the Remote-SSH command (the plain
|
|
167
|
+
attached-container command wouldn't work from another machine and is just
|
|
168
|
+
noise). Otherwise we print the local attached-container command.
|
|
169
|
+
"""
|
|
170
|
+
if ssh_host:
|
|
171
|
+
ssh_cmd = self._format_code_command(workspace_dir, dev_name, live, new_window, ssh_host)
|
|
172
|
+
console.print(f" 📋 VS Code command (Remote-SSH: {ssh_host}):")
|
|
173
|
+
console.print(f" {ssh_cmd}")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
plain_cmd = self._format_code_command(workspace_dir, dev_name, live, new_window, None)
|
|
177
|
+
console.print(f" 📋 VS Code command:")
|
|
178
|
+
console.print(f" {plain_cmd}")
|
|
90
179
|
|
|
91
|
-
vscode_uri = f"vscode-remote://attached-container+{container_hex}/workspaces/{workspace_name}"
|
|
92
|
-
else:
|
|
93
|
-
# Original behavior: create new container from devcontainer.json
|
|
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.
|
|
97
|
-
workspace_name = workspace_dir.name if live else self.project.get_workspace_name(dev_name)
|
|
98
|
-
vscode_uri = f"vscode-remote://dev-container+{workspace_hex}/workspaces/{workspace_name}"
|
|
99
|
-
|
|
100
|
-
return vscode_uri
|
|
101
|
-
|
|
102
180
|
def launch_devcontainer(
|
|
103
181
|
self,
|
|
104
182
|
workspace_dir: Path,
|
|
@@ -127,11 +205,6 @@ class VSCodeIntegration:
|
|
|
127
205
|
workspace_dir, dev_name, live, attach_to_existing=True, ssh_host=ssh_host
|
|
128
206
|
)
|
|
129
207
|
|
|
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
208
|
cmd = ["code"]
|
|
136
209
|
|
|
137
210
|
if new_window:
|
|
@@ -139,6 +212,34 @@ class VSCodeIntegration:
|
|
|
139
212
|
|
|
140
213
|
cmd.extend(["--folder-uri", vscode_uri])
|
|
141
214
|
|
|
215
|
+
# Always print the command(s) so they can be copied and run elsewhere.
|
|
216
|
+
self._print_code_commands(workspace_dir, dev_name, live, new_window, ssh_host)
|
|
217
|
+
|
|
218
|
+
# No local 'code' command (e.g. a headless remote dev box reached over SSH):
|
|
219
|
+
# don't fail — just point to the command printed above and stop here.
|
|
220
|
+
if not self.code_available:
|
|
221
|
+
if ssh_host:
|
|
222
|
+
# SSH mode is attach-only: we never created/started the container,
|
|
223
|
+
# so we can't claim it's ready. The user must ensure it's running
|
|
224
|
+
# on the remote host themselves.
|
|
225
|
+
console.print(
|
|
226
|
+
f" ⚠️ VS Code 'code' command not found here — printed the attach "
|
|
227
|
+
f"URI only. Make sure the container is already running on '{ssh_host}', "
|
|
228
|
+
"then run the command above from a machine with VS Code installed."
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
console.print(
|
|
232
|
+
f" ⚠️ VS Code 'code' command not found here — container is ready, "
|
|
233
|
+
"but VS Code can't be launched from this machine. "
|
|
234
|
+
"Run the command above from a machine with VS Code installed."
|
|
235
|
+
)
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
if ssh_host:
|
|
239
|
+
console.print(f" 🚀 Opening VS Code for: {dev_name} (via SSH: {ssh_host})")
|
|
240
|
+
else:
|
|
241
|
+
console.print(f" 🚀 Opening VS Code for: {dev_name}")
|
|
242
|
+
|
|
142
243
|
# Set environment variables using shared function
|
|
143
244
|
container_workspace_name = self.project.get_workspace_name(dev_name)
|
|
144
245
|
env = prepare_devcontainer_environment(
|
|
@@ -177,6 +278,7 @@ class VSCodeIntegration:
|
|
|
177
278
|
delay_between_windows: float = 2.0,
|
|
178
279
|
live: bool = False,
|
|
179
280
|
ssh_host: Optional[str] = None,
|
|
281
|
+
ssh_hosts: Optional[dict] = None,
|
|
180
282
|
) -> int:
|
|
181
283
|
"""Launch multiple devcontainers in separate VS Code windows.
|
|
182
284
|
|
|
@@ -185,7 +287,10 @@ class VSCodeIntegration:
|
|
|
185
287
|
dev_names: List of development environment names
|
|
186
288
|
delay_between_windows: Delay between opening windows (seconds)
|
|
187
289
|
live: Whether to use live mode (mount current directory)
|
|
188
|
-
ssh_host: If set, connect via Remote-SSH to this host
|
|
290
|
+
ssh_host: If set, connect every dev via direct Remote-SSH to this host.
|
|
291
|
+
ssh_hosts: Optional per-dev override mapping ``dev_name -> ssh host``
|
|
292
|
+
(each container is its own tailnet host, so they differ). Falls back
|
|
293
|
+
to ``ssh_host`` for any dev not present in the map.
|
|
189
294
|
|
|
190
295
|
Returns:
|
|
191
296
|
Number of successfully launched windows
|
|
@@ -193,10 +298,11 @@ class VSCodeIntegration:
|
|
|
193
298
|
if len(workspace_dirs) != len(dev_names):
|
|
194
299
|
raise VSCodeError("Workspace directories and dev names lists must have same length")
|
|
195
300
|
|
|
196
|
-
|
|
301
|
+
ssh_hosts = ssh_hosts or {}
|
|
302
|
+
if ssh_host or ssh_hosts:
|
|
197
303
|
console.print(
|
|
198
304
|
f"📂 Opening {len(dev_names)} devcontainers in VS Code "
|
|
199
|
-
f"(via SSH
|
|
305
|
+
f"(via Remote-SSH) for project: {self.project.info.name}"
|
|
200
306
|
)
|
|
201
307
|
else:
|
|
202
308
|
console.print(f"📂 Opening {len(dev_names)} devcontainers in VS Code for project: {self.project.info.name}")
|
|
@@ -204,9 +310,10 @@ class VSCodeIntegration:
|
|
|
204
310
|
success_count = 0
|
|
205
311
|
|
|
206
312
|
for workspace_dir, dev_name in zip(workspace_dirs, dev_names):
|
|
313
|
+
host = ssh_hosts.get(dev_name, ssh_host)
|
|
207
314
|
try:
|
|
208
315
|
if self.launch_devcontainer(
|
|
209
|
-
workspace_dir, dev_name, new_window=True, live=live, ssh_host=
|
|
316
|
+
workspace_dir, dev_name, new_window=True, live=live, ssh_host=host
|
|
210
317
|
):
|
|
211
318
|
success_count += 1
|
|
212
319
|
|
|
@@ -217,7 +324,7 @@ class VSCodeIntegration:
|
|
|
217
324
|
console.print(f" ❌ Failed to launch {dev_name}: {e}")
|
|
218
325
|
continue
|
|
219
326
|
|
|
220
|
-
if success_count > 0:
|
|
327
|
+
if success_count > 0 and self.code_available:
|
|
221
328
|
console.print("")
|
|
222
329
|
console.print(f"💡 VS Code windows should open shortly with titles: '<dev-name> - {self.project.info.directory.name}'")
|
|
223
330
|
|
|
@@ -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
|
|
File without changes
|