devs-cli 4.0.7__py3-none-any.whl → 5.0.1__py3-none-any.whl

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.py CHANGED
@@ -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
- '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. '
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 --ssh myhost.tailnet.ts.net # Attach to a container ALREADY running on myhost
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
- # 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
+ 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
- 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
-
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
- valid_dev_names,
307
+ ssh_dev_names,
297
308
  delay_between_windows=delay,
298
309
  live=live,
299
- ssh_host=ssh_host,
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
- container_manager = ContainerManager(project, config)
309
- workspace_manager = WorkspaceManager(project, config)
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
- workspace_dirs = []
312
- valid_dev_names = []
322
+ workspace_dirs = []
323
+ valid_dev_names = []
313
324
 
314
- for dev_name in dev_names:
315
- console.print(f" Preparing: {dev_name}")
325
+ for dev_name in local_dev_names:
326
+ console.print(f" Preparing: {dev_name}")
316
327
 
317
- devs_env = DevsConfigLoader.load_env_vars(dev_name, project.info.name)
318
- cli_env = parse_env_vars(env) if env else {}
319
- extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
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
- if extra_env:
322
- console.print(f"🔧 Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
332
+ if extra_env:
333
+ console.print(f"🔧 Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
323
334
 
324
- try:
325
- workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
335
+ try:
336
+ workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
326
337
 
327
- if container_manager.ensure_container_running(dev_name, workspace_dir, check_rebuild=False, debug=debug, live=live, extra_env=extra_env):
328
- workspace_dirs.append(workspace_dir)
329
- valid_dev_names.append(dev_name)
330
- else:
331
- console.print(f" ❌ Failed to start container for {dev_name}, skipping...")
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
- except (ContainerError, WorkspaceError) as e:
334
- console.print(f" ❌ Error preparing {dev_name}: {e}")
335
- continue
344
+ except (ContainerError, WorkspaceError) as e:
345
+ console.print(f" ❌ Error preparing {dev_name}: {e}")
346
+ continue
336
347
 
337
- if workspace_dirs:
338
- try:
339
- success_count = vscode_integration.launch_multiple_devcontainers(
340
- workspace_dirs,
341
- valid_dev_names,
342
- delay_between_windows=delay,
343
- live=live,
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
- if success_count == 0:
347
- console.print("❌ Failed to open any VS Code windows")
357
+ if success_count == 0:
358
+ console.print("❌ Failed to open any VS Code windows")
348
359
 
349
- except VSCodeError as e:
350
- console.print(f"❌ VS Code integration error: {e}")
360
+ except VSCodeError as e:
361
+ console.print(f"❌ VS Code integration error: {e}")
351
362
 
352
363
 
353
364
  @cli.command()
devs/core/integration.py CHANGED
@@ -11,6 +11,7 @@ from rich.console import Console
11
11
 
12
12
  from ..exceptions import VSCodeError
13
13
  from devs_common.core.project import Project
14
+ from devs_common.utils.config_hash import get_env_mount_path
14
15
  from devs_common.utils.devcontainer import prepare_devcontainer_environment
15
16
 
16
17
  console = Console()
@@ -51,6 +52,41 @@ class VSCodeIntegration:
51
52
  except FileNotFoundError:
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,38 +102,34 @@ 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 + attached-container URI for this host.
70
- The host can be a Tailscale MagicDNS name or any SSH-reachable hostname.
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
  """
75
- if attach_to_existing:
76
- container_name = self.project.get_container_name(dev_name)
77
- workspace_name = workspace_dir.name if live else self.project.get_workspace_name(dev_name)
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)
78
116
 
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()
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}"
90
124
 
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}"
125
+ if attach_to_existing:
126
+ container_name = self.project.get_container_name(dev_name)
127
+ container_hex = container_name.encode("utf-8").hex()
128
+ return f"vscode-remote://attached-container+{container_hex}/workspaces/{workspace_name}"
99
129
 
100
- return vscode_uri
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}"
101
133
 
102
134
  def _format_code_command(
103
135
  self,
@@ -129,15 +161,17 @@ class VSCodeIntegration:
129
161
  new_window: bool,
130
162
  ssh_host: Optional[str],
131
163
  ) -> None:
132
- """Print the 'code' command(s) for this container.
164
+ """Print the 'code' command for this container.
133
165
 
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.
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.
136
169
  """
137
170
  if ssh_host:
138
171
  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}):")
172
+ console.print(f" 📋 VS Code command (Remote-SSH: {ssh_host}):")
140
173
  console.print(f" {ssh_cmd}")
174
+ return
141
175
 
142
176
  plain_cmd = self._format_code_command(workspace_dir, dev_name, live, new_window, None)
143
177
  console.print(f" 📋 VS Code command:")
@@ -182,14 +216,23 @@ class VSCodeIntegration:
182
216
  self._print_code_commands(workspace_dir, dev_name, live, new_window, ssh_host)
183
217
 
184
218
  # 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.
219
+ # don't fail — just point to the command printed above and stop here.
187
220
  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
- )
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
+ )
193
236
  return True
194
237
 
195
238
  if ssh_host:
@@ -235,6 +278,7 @@ class VSCodeIntegration:
235
278
  delay_between_windows: float = 2.0,
236
279
  live: bool = False,
237
280
  ssh_host: Optional[str] = None,
281
+ ssh_hosts: Optional[dict] = None,
238
282
  ) -> int:
239
283
  """Launch multiple devcontainers in separate VS Code windows.
240
284
 
@@ -243,7 +287,10 @@ class VSCodeIntegration:
243
287
  dev_names: List of development environment names
244
288
  delay_between_windows: Delay between opening windows (seconds)
245
289
  live: Whether to use live mode (mount current directory)
246
- ssh_host: If set, connect via Remote-SSH to this host then attach to each container.
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.
247
294
 
248
295
  Returns:
249
296
  Number of successfully launched windows
@@ -251,10 +298,11 @@ class VSCodeIntegration:
251
298
  if len(workspace_dirs) != len(dev_names):
252
299
  raise VSCodeError("Workspace directories and dev names lists must have same length")
253
300
 
254
- if ssh_host:
301
+ ssh_hosts = ssh_hosts or {}
302
+ if ssh_host or ssh_hosts:
255
303
  console.print(
256
304
  f"📂 Opening {len(dev_names)} devcontainers in VS Code "
257
- f"(via SSH: {ssh_host}) for project: {self.project.info.name}"
305
+ f"(via Remote-SSH) for project: {self.project.info.name}"
258
306
  )
259
307
  else:
260
308
  console.print(f"📂 Opening {len(dev_names)} devcontainers in VS Code for project: {self.project.info.name}")
@@ -262,9 +310,10 @@ class VSCodeIntegration:
262
310
  success_count = 0
263
311
 
264
312
  for workspace_dir, dev_name in zip(workspace_dirs, dev_names):
313
+ host = ssh_hosts.get(dev_name, ssh_host)
265
314
  try:
266
315
  if self.launch_devcontainer(
267
- workspace_dir, dev_name, new_window=True, live=live, ssh_host=ssh_host
316
+ workspace_dir, dev_name, new_window=True, live=live, ssh_host=host
268
317
  ):
269
318
  success_count += 1
270
319
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 4.0.7
3
+ Version: 5.0.1
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -0,0 +1,13 @@
1
+ devs/__init__.py,sha256=dMk0J_JrmBmknPvt6HNUa04qxgHYpxhnIXa3PZFk_N0,571
2
+ devs/cli.py,sha256=inWDnumsDeaQcvY8y42HcNPY-HJxYkCOUGHgsjYx4nI,44985
3
+ devs/config.py,sha256=DaS2_1x0h63cHn34RmWbEhUx_1u3-sAo16tM3AXMKfQ,1561
4
+ devs/exceptions.py,sha256=7xO7ihJu_U6CupZPMv89B4N5EBSUcdj2OdA6GPAFPEE,490
5
+ devs/core/__init__.py,sha256=TPy3eZi-AEztci_QZ37YAAl2zvUQlwBALpyiQx2ED7Q,363
6
+ devs/core/integration.py,sha256=_zT5kJi_L34uU3yhLinLI7bbjI5Q27dX9ABxgwHkx2Y,16557
7
+ devs/utils/__init__.py,sha256=f-sEWETPfW2Xkaxgd-l6FS-jIQAorLlQZOicIZvp-W0,638
8
+ devs_cli-5.0.1.dist-info/licenses/LICENSE,sha256=bi2EUiv-lmC_quQVkNqzTYXJpjVarkPsVKfqhJl7ccQ,1067
9
+ devs_cli-5.0.1.dist-info/METADATA,sha256=6H4bVWMkczracf1u_hnfn6egnuuVwGtkyAsWzq_Jbww,5282
10
+ devs_cli-5.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ devs_cli-5.0.1.dist-info/entry_points.txt,sha256=s8-pgqZ1QvzPJgHP9vNxwBYdezmGsFdRUHacJPZ4WOs,39
12
+ devs_cli-5.0.1.dist-info/top_level.txt,sha256=9RIVUPVGuOdO84qkBh_9XcKoTV7773o3py78wz2-IZk,5
13
+ devs_cli-5.0.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,13 +0,0 @@
1
- devs/__init__.py,sha256=dMk0J_JrmBmknPvt6HNUa04qxgHYpxhnIXa3PZFk_N0,571
2
- devs/cli.py,sha256=wRqKwdyGYAqemrabrH7NhMpBYX4iFJpowWxwDqptYmk,44125
3
- devs/config.py,sha256=DaS2_1x0h63cHn34RmWbEhUx_1u3-sAo16tM3AXMKfQ,1561
4
- devs/exceptions.py,sha256=7xO7ihJu_U6CupZPMv89B4N5EBSUcdj2OdA6GPAFPEE,490
5
- devs/core/__init__.py,sha256=TPy3eZi-AEztci_QZ37YAAl2zvUQlwBALpyiQx2ED7Q,363
6
- devs/core/integration.py,sha256=cM3bIok06Z4faIkTyF8h35FeKdHpnx3_h2mXnJ1SaW0,13935
7
- devs/utils/__init__.py,sha256=f-sEWETPfW2Xkaxgd-l6FS-jIQAorLlQZOicIZvp-W0,638
8
- devs_cli-4.0.7.dist-info/licenses/LICENSE,sha256=bi2EUiv-lmC_quQVkNqzTYXJpjVarkPsVKfqhJl7ccQ,1067
9
- devs_cli-4.0.7.dist-info/METADATA,sha256=kak8KcJlQeXQGLObaeobYBGZWS9CsCFOOaRYMu9j6B0,5282
10
- devs_cli-4.0.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
- devs_cli-4.0.7.dist-info/entry_points.txt,sha256=s8-pgqZ1QvzPJgHP9vNxwBYdezmGsFdRUHacJPZ4WOs,39
12
- devs_cli-4.0.7.dist-info/top_level.txt,sha256=9RIVUPVGuOdO84qkBh_9XcKoTV7773o3py78wz2-IZk,5
13
- devs_cli-4.0.7.dist-info/RECORD,,